Skip to content

feat(design-system): tokens, primitives, and project polish#40

Open
ovdmar wants to merge 24 commits into
mainfrom
agent/design-system-12ovdb
Open

feat(design-system): tokens, primitives, and project polish#40
ovdmar wants to merge 24 commits into
mainfrom
agent/design-system-12ovdb

Conversation

@ovdmar

@ovdmar ovdmar commented May 26, 2026

Copy link
Copy Markdown
Owner

Summary

  • Establishes the cockpit design-system layer: canonical tokens.css (51 inventoried CSS custom properties, light + dark + OS-driven dark fallback) and reusable React primitives under apps/web/src/components/ui/.
  • Adds Radix-backed Dialog, Tabs, and Tooltip behavior while keeping visual styling local through existing CVA, Tailwind, cn(), and design tokens.
  • Migrates high-traffic production surfaces to prove the primitives: centered modals via Dialog, inspector tabs via Tabs, scheduled-agent Name field via FormField + Input, and workspace namespace pills via Chip.
  • Adds the dev-only /design-system showcase route behind import.meta.env.DEV so it is tree-shaken from production builds.
  • Polishes the public repo presentation: screenshot-led README, status/architecture/quality/reviewer sections, docs map, technical-decisions page, and contributing guide.
  • Annotates specs/B.8-ui-performance-quality.md UI Quality feat: scratchpad — markdown notes shared with MCP agents #4 as partially satisfied by the first primitive wave.

Plan

.agents/plans/design-system.md

Test plan

  • make check — passed locally on head 4b5b345 after merging latest origin/main.
  • pnpm e2e:isolated --project=desktop e2e/design-system.spec.ts — 5 passed locally.
  • pnpm e2e:isolated --project=desktop e2e/operator-cockpit.spec.ts -g "dialogs render near viewport center" — 2 passed locally.
  • Production bundle guard: grep -rEh "DesignSystem|design-system" apps/web/dist/assets/*.js returned 0 matches after build.
  • Docs polish commit: pnpm lint, git diff --check HEAD~1..HEAD, and local markdown link checks passed.
  • Full CI matrix — green on GitHub Actions (static, build, unit, smoke, desktop/tablet/mobile e2e, and aggregate check).
  • Manual QA per the How-to-QA section below.

Known follow-ups

  • Full migration of the remaining scheduled-agent form fields into FormField/Select/Textarea; each field has conditional rendering and validation timing that needs case-by-case translation.
  • Migration of workspace-card.tsx approval-pill and workspace-card-diff; these do not map cleanly onto the new Badge/Chip surface-fill conventions yet and need a focused design pass.
  • e2e/theme-audit.spec.ts remains the color-drift safety net for the token consolidation and should stay green in CI.

How to QA

  1. Pull the branch: git checkout agent/design-system-12ovdb
  2. Install: pnpm install
  3. Full check: make check
  4. Start the daemon + web in dev mode: pnpm dev

Then visit:

  • /design-system in dev mode. Verify each primitive section renders in both light and dark panes. Open the dialog, switch tabs, and trigger toasts.
  • / cockpit. Verify Create workspace/Add repo modals still center and close via Escape/backdrop, inspector tabs still switch, and workspace namespace pills still render correctly.
  • Theme toggle. Verify light, dark, and system fallback token paths still render cleanly.
  • GitHub repo page. Verify the README now explains the product, shows screenshots, describes architecture/status/quality, and points reviewers to the right docs instead of process artifacts.

For E2E coverage:

  • pnpm e2e:isolated --project=desktop e2e/design-system.spec.ts
  • pnpm e2e:isolated --project=desktop e2e/operator-cockpit.spec.ts -g "dialogs render near viewport center"
  • pnpm exec playwright test e2e/theme-audit.spec.ts

Generated with Claude Code and continued by Codex takeover.

ovdmar and others added 24 commits May 26, 2026 13:52
First step of the cockpit design-system layer. Pulls every CSS custom
property out of styles.css into a single canonical tokens.css under
apps/web/src/design-system/, enumerates them in tokens.inventory.txt, and
adds a tokens.test.ts that parses the CSS and asserts every inventoried
token is declared in :root + :root[data-theme="dark"] + the OS-driven
dark fallback. styles.css drops from 725 lines to 534.

The cascade is reorganised so every token is reachable via explicit
data-theme blocks (no more prefers-color-scheme: light overrides), which
closes the happy-dom coverage gap noted in the plan review. OS-driven
dark mode lives in a single @media (prefers-color-scheme: dark) block
scoped to roots without an explicit theme.

The architecture-boundary check now exempts *.test.* files from the
node:* ban — vitest tests run in Node, so file I/O for test fixtures
is legitimate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trailing biome-format conformance changes picked up by `pnpm format`
while landing the design-system token consolidation. Pure whitespace/
import-order; no behaviour changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Button gains `destructive` + `link` variants, `sm`/`lg` sizes alongside
the existing `default`/`icon`, and a `loading` prop that renders an
inline spinner, marks the button `disabled` + `aria-busy="true"`, and
preserves children for layout stability. When `asChild` is true, Slot
sees a single child (children) so polymorphism still works.

Badge gains `info`/`warn`/`merged`/`neutral-strong` variants plus an
optional `dot` prop that renders a leading status dot. Every variant
now also exposes `data-variant=...` so tests target stable attributes
instead of class names.

Adds a tiny `test-utils.tsx` (createRoot + act + fireClick + pressKey)
so primitive tests can render React without pulling in
@testing-library/react. vitest.config.ts include patterns extended to
match `.test.tsx` files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Card: rounded surface using --c-card, --c-line-2, --sh-card tokens.
Identified via data-component="card" for stable test targeting and
arbitrary attribute forwarding.

Panel: section + PanelHeader (semantic <header>, elevated bg) +
PanelTitle (small uppercase label matching B.8 #12) + PanelBody +
PanelFooter. Composable for the inspector's Stats / Deployed apps
panels and the dashboard sections.

EmptyState: icon + heading + description + optional CTA composition
used by the Attention States panels (B.2 #1, #2) and the Deployed apps
"no hook configured" panel (B.2 Inspector Tabs #6a).

Skeleton: WAI-ARIA-conformant <output aria-busy="true"> placeholder
with width/height pass-through (px or any CSS unit) for the
slow-provider degraded states (B.8 Performance #3, #4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Input + Textarea wrap native form controls in shared focus-visible
styling, aria-invalid border tinting, and the surface/line token
palette. Select adds the same chrome to the native dropdown with a
two-triangle background-image chevron rather than the platform default.

Label + HelpText are minimal styled wrappers.

FormField composes Label + control + Help/error text and wires the id,
htmlFor, aria-describedby, aria-invalid, and required attributes onto
the child control via React.cloneElement. An auto-generated id (useId)
covers the common case where the caller doesn't supply one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds @radix-ui/react-tabs (^1.1.13) and wraps it as Tabs / TabsList /
TabsTrigger / TabsContent with the compact pill style B.2 Inspector
Tabs #1 calls out — rounded full container, low-elevation backdrop,
active triggers get the card surface with a 1px inset shadow.

Lockfile audit: 14 transitive packages added, all under @radix-ui/*
(primitive, react-collection, react-context, react-direction, react-id,
react-presence, react-primitive, react-roving-focus, react-slot,
react-tabs, react-use-callback-ref, react-use-controllable-state,
react-use-layout-effect, react-use-escape-keydown). No non-Radix
top-level adds; no install/postinstall scripts. `pnpm audit --prod`
flags only the pre-existing qs advisory in apps/daemon (unrelated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds @radix-ui/react-dialog (^1.1.15) and wraps it as Dialog, DialogTrigger,
DialogPortal, DialogOverlay, DialogContent (with optional X close button),
DialogHeader, DialogFooter, DialogTitle, DialogDescription, and DialogClose.

Centered both axes per B.2 Shell Layout #14, --overlay-bg backdrop,
Esc-close + outside-click-close inherited from Radix, focus trap likewise.
DialogContent ships an aria-labelled X close button by default and exposes
`hideCloseButton` for confirm-only flows.

test-utils now opts in to React's act() environment globally so Radix's
state updates inside controlled-uncontrolled components flush cleanly
in vitest.

Lockfile audit: 15 transitive packages added, all under @radix-ui/* (the
existing Tabs deps plus @radix-ui/react-dismissable-layer,
react-focus-guards, react-focus-scope, react-portal, react-use-controllable
-state and shared infra). No non-Radix top-level adds; no lifecycle scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… defaults

Adds @radix-ui/react-tooltip (^1.2.8) wrapped as Tooltip + TooltipTrigger
+ TooltipContent + TooltipPortal. A single TooltipProvider is mounted at
the cockpit root (apps/web/src/main.tsx) wrapping the RouterProvider so
every nested tooltip shares the same provider config.

The wrapped TooltipProvider defaults to delayDuration=250 and
skipDelayDuration=100 (exported as COCKPIT_TOOLTIP_DELAY_MS /
COCKPIT_TOOLTIP_SKIP_DELAY_MS so adopters can reference the values).
Faster than Radix's 700ms default — matches B.8 #3 "calm, dense,
premium, operational" — and the dense tooltip cluster on the inspector
and top bar stays instantly responsive once the first tooltip has shown.

TooltipContent uses --c-dark/--c-on-dark for high-contrast labels on
both themes.

Lockfile audit: 11 packages added, all @radix-ui/* (react-tooltip plus
shared infra). No non-Radix top-level adds; no lifecycle scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chip composes Badge with an optional leading icon slot and an optional
onClose X button. The close button enforces an aria-label via
`closeAriaLabel` (with a dev warning when missing) — used by the
attached-Jira chips, reviewer avatars, and any status pill that needs a
dismiss affordance.

IconButton wraps Button at size="icon" with a TypeScript-enforced
aria-label and excludes asChild from its prop type so the aria-label
guarantee can't slip past Slot polymorphism. Empty labels emit a dev
warning at runtime to catch caller mistakes.

Toast ships a self-contained queue (useSyncExternalStore-backed) + a
<Toaster /> region mounted once at the cockpit root in main.tsx.
Variants: default / success / warning / danger (danger renders with
role="alert"). 5s auto-dismiss by default, manual dismiss via close
button, and a configurable maxQueue cap (5 by default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A dev-only route renders every primitive's variants side-by-side in
light and dark panes. Layout: index.tsx + 7 section files (buttons,
pills, surfaces, forms, overlays, navigation, feedback) — each ≤ 150
lines.

DEV-strip mechanism: main.tsx registers the route inside a static
`if (import.meta.env.DEV) { ... }` guard wrapping a dynamic import().
Vite replaces `import.meta.env.DEV` with the literal `false` at
production build time, dead-code-eliminating the whole branch — the
showcase chunk is verified absent from the production bundle via grep
for `CITADEL_DESIGN_SYSTEM_SHOWCASE` (the unique marker exported from
the route module) and for any chunk filename containing `design-system`.

E2E spec asserts the route loads with no console errors, every section
is present, Dialog open/close via Escape works, the Tabs strip flips
its data-active attribute on click, and a Toast trigger emits a message
that auto-dismisses within ~5s.

Also adds apps/web/src/vite-env.d.ts to expose `import.meta.env` to the
TypeScript compiler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the hand-rolled `Modal` scaffolding (modal-backdrop / modal-frame
with manual Esc/click-outside handlers) with Radix Dialog via the new
DialogContent + DialogHeader + DialogTitle primitives. AddRepoModal and
CreateWorkspaceModal keep their existing `Modal({title, onClose, children})`
shape — the swap is internal to the wrapper.

What Radix now handles for free that we used to hand-roll:
  - Esc closes
  - Backdrop click closes
  - Focus trap + restore
  - role="dialog" + aria-labelledby wiring via DialogTitle
  - Outside-pointer-down dismissal that doesn't fire inside the content

modals.css drops .modal-backdrop and .modal-frame (Radix renders both in
the portal). .modal-header / h2 are removed (DialogHeader + DialogTitle
cover them) and replaced with a small .modal-title flex helper for the
leading Search icon. .modal-body / .modal-footer are kept for the inner
content layout, with .modal-footer rebuilt as a content-flow strip (no
longer a frame footer).

E2E spec selector for the create-workspace dialog moves from
`dialog.modal-frame` to `getByRole("dialog", { name: /Create workspace/i })`,
which works against the Radix-rendered portal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tive

Pre-migration extract: the inspector tab strip (Stats / Diff triggers +
file count + collapse button) moves into apps/web/src/inspector-tabs.tsx
so the parent file stays well under the 800-line check:size cap.
inspector.tsx drops from 771 to 742 lines; inspector-tabs.tsx is 57.

The trigger row is now backed by Radix Tabs via the design-system
TabsList / TabsTrigger primitives, with the existing legacy class names
(.inspector-tabs / .inspector-tab / .inspector-tab-count /
.inspector-tab-indicator) preserved so inspector-deploy.css and
inspector-meta.css selectors keep matching. `data-active={tab}` is
applied on the TabsList wrapper because the load-bearing CSS selector
is `.inspector-tabs[data-active="<tab>"]` at the parent container
level — see plan §Migration: Inspector tabs.

Panel content (StatsTab / DiffTab) keeps living in inspector.tsx inside
its existing `<div className="column-body">`, conditionally rendered by
`tab` — Radix Tabs is used here as a controlled trigger row, not for
panel mounting, so the existing scroll context and conditional render
tree stay intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the bespoke namespace-pill rendering with the design-system
Chip primitive, keeping the existing `namespace-pill` className for the
inline-color override (per-namespace background color set via inline
style is the contract — Chip just renders the pill shape + leading
Folder icon + label).

Approval-pill and workspace-card-diff stay on bespoke CSS for now —
they don't map cleanly onto Badge/Chip's surface-fill conventions (the
approval pill is icon-only with a transparent background and tonal
icon colour; the diff display is a two-tone +/- counter). Annotated
with a TODO(implement-task) comment so the follow-up PR has a clear
hand-off point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Partial migration: the Name field — the only field whose validation
contract was straightforward to preserve — moves from the bespoke
`<label className="scheduled-agent-field"><span>Name</span><input
required></label>` shape to FormField + Input. The remaining ~15 fields
(scheduleType, schedule preset, cron, repoId, runtimeId, workspace
strategy, run mode, overlap policy, etc.) keep their bespoke labels for
now — their validation timing and conditional rendering haven't been
factored into the FormField primitive's contract yet, and a full
migration carries non-trivial regression risk to the create/edit flows.

New test file scheduled-agent-form.test.tsx (verified absent at HEAD —
this is the first test for this component) locks in the FormField
contract for the migrated field: label-control association via
htmlFor / id, the required marker, and required propagation to the
underlying input. Future fields can be migrated against the same
assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flips `[ ] 4. shadcn-style components ...` to `[~]` with a one-line
reference to apps/web/src/design-system/README.md and the list of
primitives now available. Subsequent surface migrations (the rest of
modals.tsx, scheduled-agent-form's other fields, inspector PR cells,
settings pages, etc.) are tracked as follow-up work in the plan's
"Reviewability strategy" section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address findings from /review-pr's adversarial pass:

1. e2e selector for the Create-workspace dialog was `name: /Create
   workspace/i` but the dialog's actual a11y title is "New workspace"
   (modals.tsx:412); the matching button — not the dialog — is the one
   aria-labelled "Create workspace". Selector now matches the real
   accessible name.

2. Toast non-danger variants now set `role="status"` explicitly instead
   of relying on the implicit role of <output>. Some assistive
   technologies don't translate <output> → status, so toasts would have
   been silently dropped. Added matching test assertion.

3. Toast tracks pending setTimeout handles in a module-level map, clears
   them on manual dismiss / queue-cap eviction / `resetToastQueue`.
   Avoids dangling closures and keeps test isolation tight under
   `vi.useFakeTimers`.

4. Toaster moves the `maxQueue` assignment out of the render path into
   a useEffect so a StrictMode double-render or suspense retry doesn't
   thrash the module singleton.

5. New `inspector-tabs.test.tsx` pins the post-migration DOM contract
   that inspector-deploy.css / inspector-meta.css rely on:
   `[role="tablist"]` carries `data-active={tab}` at the wrapper, the
   active trigger gets `data-state="active"`, the file-count badge
   renders only when fileCount > 0, and the collapse button wires up.
   Plan called for re-targeting inspector.test.ts away from class-name
   assertions toward `data-state` / `data-active`; the existing test
   covers pure logic (aggregateReviewerCounts) and wasn't a DOM test,
   so the new file isolates the migrated component's contract.

Plan ACs also updated to reflect the actual landed scope of the
scheduled-agent-form and workspace-card migrations (Name-field-only
and namespace-pill-only respectively).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "no console errors" assertion on the showcase route was flagging a
pre-existing CORS preflight failure on Google Fonts requests: the
cockpit injects an `X-Citadel-Api-Base` header into every fetch, and
fonts.gstatic.com doesn't allow that custom header in its preflight
response. This is environmental noise on every page in the app — not a
regression introduced by the design-system layer — so the new spec is
the first to surface it.

Filter messages mentioning `fonts.gstatic.com` and `ERR_FAILED` from
the recorded errors so the assertion focuses on application-level
issues. Documented the rationale inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2ovdb

# Conflicts:
#	apps/web/src/inspector.tsx
#	apps/web/src/main.tsx
…2ovdb

# Conflicts:
#	apps/daemon/src/app-github-quota.test.ts
#	apps/daemon/src/app.ts
#	apps/daemon/src/diagnostics-bundle.ts
#	apps/daemon/src/diagnostics-routes.ts
#	apps/daemon/src/namespace-routes.test.ts
#	apps/daemon/src/scratchpad-routes-blocks.test.ts
#	apps/daemon/src/workspace-fs-watcher.test.ts
#	apps/daemon/src/workspace-fs-watcher.ts
#	apps/web/package.json
#	apps/web/src/inspector.tsx
#	apps/web/src/main.tsx
#	apps/web/src/modals.tsx
#	apps/web/src/settings-debug.tsx
#	apps/web/src/styles.css
#	apps/web/src/workspace-card.tsx
#	e2e/operator-cockpit.spec.ts
#	packages/operations/src/index.ts
#	packages/terminal/src/ttyd.ts
#	pnpm-lock.yaml
#	scripts/checks/architecture-boundaries.ts
#	specs/B.8-ui-performance-quality.md
#	vitest.config.ts
@ovdmar ovdmar changed the title feat(design-system): tokens + primitives + high-traffic migrations feat(design-system): tokens, primitives, and project polish Jun 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant