Files
ccs/ui/docs/design-system.md
T
Tam Nhu Tran 53030891c6 feat(ui/design-system): codify rail-anchored layout invariants and palette
Adds a page-level design system spec for CCS dashboard Config pages,
without migrating any production page yet. Pure-spec PR: docs +
primitive treatments + DEV-only /_styleguide route. Production page
adoption is intentionally left for follow-up work, one page at a time.

Spec — `ui/docs/design-system.md`:

- §0 Layout invariants (NEVER VIOLATE): two-column shell, no second
  horizontal strip below the global topbar, sibling-pane top alignment,
  cliproxy as canonical reference, user-resizable form/json split, and
  content-fit unified rail (`w-fit min-w-[240px] max-w-[360px]`)
- §1 identity-strip patterns: HeroBar (home), rail-anchored (cliproxy),
  PageHeader (Monitor-only), Bespoke (health)
- §2 body archetypes: Config (rail + form + optional json) and Monitor
- §4 anti-patterns with rejected code samples (PageHeader-over-
  ConfigLayout, tab-bar offsetting json pane, redundant description
  band, blank vertical band)
- §5 color & accent rules using the existing Pampas/Crail palette only:
  1-accent-dot rule, sibling-pane differentiation, status pill spec,
  three-tier intensity ladder, sensitive-field treatment per §5g

Decisions log — `ui/docs/design-decisions.md`:

- v1.6: content-fit rail (replaces fixed 260px)
- v1.5: color & accent treatment for body panes
- v1.4: resizable form/json split
- v1.3: layout invariants and rail-anchored default for Config

Primitives — `ui/src/components/config-layout/`:

- ConfigLayout: rail rendered as content-fit `<aside>` within the
  unified envelope; form↔json split via react-resizable-panels (already
  in deps) with autoSaveId-driven per-page localStorage persistence
- FormPane: 1px Crail top strip on header + soft from-card gradient +
  inset bottom shadow for scroll-depth cue; body wash bg-muted/20 so
  FormSections read as elevated cards; footer bg-muted/40 anchors save
- FormSection: 2px Crail leading-edge stripe with three-tier intensity
  (default 30% / hover 70% / focus-within solid + ring + shadow) + 1.5px
  accent dot prefixing each title
- JsonPane: bg-muted/30 shell visually recesses the read-only pane vs
  the active form pane (bg-card); accent dot + status pill in header;
  inner <pre> sits inside an inset-shadow well; content stays plain
  (a dedicated JSON viewer with its own color coding will replace it
  later — pages MUST NOT add inline JSON tints in the meantime)
- ListPane: selected row uses three-tier intensity ladder (no stripe at
  rest, accent/30 stripe on hover, solid Crail stripe + bg-accent/10
  row tint + accent-tinted icon and count badge on selection)

Sensitive input primitive — `ui/src/components/ui/masked-input.tsx`:

Upgraded to match §5g: lock glyph prefixing label, "sensitive" accent
pill on the label row, accent focus ring (the only place the default
ring is overridden), accent-tinted reveal toggle on hover. Existing
consumers (settings-dialog, websearch, friendly-ui-section, env-editor-
section) inherit the treatment without code changes.

Shared Field — `ui/src/components/forms/field.tsx`:

Tiny self-documenting Field component referenced by /_styleguide demos.
Auto-detects sensitive labels (AUTH_TOKEN | API_KEY | SECRET | PASSWORD
| PRIVATE_KEY) and applies §5g treatment. Production pages can adopt
this as a follow-up; no production code currently consumes it.

Live styleguide — `ui/src/pages/_styleguide.tsx`:

DEV-only route (gated by import.meta.env.DEV in App.tsx) showcasing
every primitive in isolation plus composed Config + Monitor archetype
demos. Intro callout surfaces the §0 invariants. §1b/§2a/§2b demos are
canonical rail-anchored examples authors can copy. Demo content uses
realistic Anthropic-style env field sets so the visual density mirrors
the cliproxy reference.

What this PR does NOT do:

- No production page rewrites — `pages/{api,accounts,codex,copilot,
  cursor,droid,shared,updates,analytics,claude-extension,cliproxy-ai-
  providers}` are unchanged
- No test selector updates — the existing test suite passes (excepting
  the 2 pre-existing account-visual-groups failures already on dev)
- No HTML preview reports — the canonical visual reference is the live
  /_styleguide page
- No new dependencies — `react-resizable-panels` was already declared

Validation: typecheck + lint + format + build clean. Test suite green
(519/521 — 2 pre-existing dev failures unrelated to this PR).
2026-04-26 13:30:03 -04:00

22 KiB
Raw Blame History

CCS Dashboard Design System

A page-level design system extracted from the canonical reference pages — home and cliproxy — that already prove the patterns work in production. New pages should adapt to these references, not the other way around.

Some pages legitimately need a bespoke design (the redesigned health page is the current example) — when content shape demands custom hierarchy, the system should step out of the way rather than force the page into a wrong-fit archetype.

Live preview in dev: bun run dev then visit /_styleguide.


0. Layout invariants (NEVER VIOLATE)

These rules precede archetype choice. When an archetype seems to require violating them, the archetype is wrong, not the rule. Code review rejects PRs that break §0.

0a. Two-column shell, full viewport height

Every Config page is a strict two-column shell:

  • Left column is the unified identity pane (rail / list). It owns page identity (brand, primary CTA, entity selector, status footer).
  • Left column width is content-fit, not fixed. The rail is unified in style and primitives across every Config page, but the actual width adapts to its header content (w-fit min-w-[240px] max-w-[360px]). The rail must never overflow or wrap its title — if the rail can't fit "API Profiles" + a "+ New" button on one line at 240px, it grows up to 360px. Beyond that, list-item labels truncate per-item. Pages MUST NOT override this envelope; if a page legitimately needs a wider rail, raise it as a system-wide envelope change (one PR adjusts the cap for everyone).
  • Right column is the main content (form, or form + json). It fills the remaining width.
  • Both columns share the same top edge, flush against the global topbar.
  • Together they fill 100vh minus the global topbar — no scrolled-up blank band above either pane, no "first page-height is just header".

0b. No horizontal strip below the global topbar

The only horizontal strip allowed at the top of the viewport is the global topbar (logo, ClaudeKit / Sponsor badges, connection status, locale, theme).

You MUST NOT stack a second strip below it — no PageHeader, no breadcrumb row, no description band, no KPI ribbon — when the body is a ConfigLayout. Identity for Config pages lives inside the left rail, where it costs zero vertical real estate.

0c. Pane top alignment

Within a ConfigLayout, all panes (left rail, form, json) share the same top edge. A tab bar inside the form pane MUST NOT push the json pane down — they are sibling columns, not parent/child. Tabs belong inside the form pane's own scroll area.

0d. The cliproxy page is the canonical Config reference

When a Config page disagrees with pages/cliproxy.tsx on layout shape, the page is wrong. Anything that produces a blank band above either column, a duplicated identity strip, or a top offset between sibling panes is a §0 violation regardless of which §1 pattern was claimed.

0e. Form and JSON panes are user-resizable

The middle (form) and right (json) panes of ConfigLayout MUST be horizontally resizable by the user via a draggable divider:

  • The left rail is unified across pages but content-fit — same envelope, same primitives, but the actual width adapts to its header content. Constraints: min-w-[240px] (floor — header controls never squeeze into a wrap) and max-w-[360px] (cap — rail can't dominate the body even when an entity label is unusually long; per-item truncate inside ListPane handles labels beyond that). The rail is NOT user-resizable — only the form↔json split is.
  • The form ↔ json divider is the only adjustable split. Users frequently want a wide form for entering env values OR a wide json pane for reading effective config — never both at the design-time defaults.
  • Default ratio: form ~45% / json ~55% of the body width remaining after the rail. The json pane is slightly larger by default because the canonical cliproxy reference shows users spend more time reading effective configuration than editing one field at a time. Persist the user's chosen ratio per-page via react-resizable-panels autoSaveId (resolves to a localStorage key).
  • Each pane has an enforced minimum width (form ≥ 360px, json ≥ 320px) so neither collapses to unreadable.
  • When the json pane is omitted (json={undefined}), the form expands to fill the remaining width — no divider rendered.
  • Below the <1024px breakpoint the layout collapses to tabs (Browse / Configure / JSON) — resizing is irrelevant in tab mode.

This satisfies the recurring need to widen one pane to inspect a long env block or read raw configuration without losing the rail-anchored shell.


1. Identity-strip patterns (pick one per page)

Three patterns cover every page in the dashboard. The choice depends on what your page already has.

1a. HeroBar — single-row dense hero

Canonical reference: pages/home.tsx

┌────────────────────────────────────────────────────────────────────┐
│ [logo]  Title  [version]   ┃  [Stat] [Stat] [Stat] [Stat]          │
└────────────────────────────────────────────────────────────────────┘

One row packs logo + title + version + ≤4 inline stats. Optional subtle dotted-pattern background. Stats are clickable when they double as navigation entry points.

Use it when:

  • The page is a dashboard / monitor with a clear product identity
  • ≤4 hero stats summarize the page in numbers
  • Vertical real estate matters (this is half the height of a stacked PageHeader + KpiRow)

Building blocks:

  • <HeroSection version={…}/> — logo + title + subtitle from components/layout/hero-section.tsx
  • <InlineStat title value icon variant onClick/> — clickable stat tile (extracted from home.tsx); promote to a shared primitive when a 2nd page adopts it

1b. Rail-anchored identity — no top chrome

Canonical reference: pages/cliproxy.tsx

┌──────────┬─────────────────────────────────────────────────────────┐
│ ⚡ Brand  │                                                         │
│ subtitle │                                                         │
│ [QSetup] │  full-height 3-pane body                                │
│          │                                                         │
│ • prov A │  (form + raw json fill the entire viewport)             │
│ • prov B │                                                         │
│  …       │                                                         │
│ [status] │                                                         │
└──────────┴─────────────────────────────────────────────────────────┘

Page identity (brand + page-level CTA + status) lives inside the left rail. Zero top chrome — the body archetype gets the full vertical viewport.

Use it when:

  • The page is a multi-entity Config (3-pane: list / form / json)
  • The rail naturally carries the page name (you'd duplicate it in a top header)
  • Vertical real estate is at a premium because the body has dense form content

Building blocks:

  • The left rail's own header section (in-place markup, no extracted primitive yet — keep it bespoke until a 2nd page adopts the pattern)
  • Recommended order in the rail: brand strip → primary CTA → entity list → status widget → footer summary

1c. PageHeader — title-row chrome (Monitor-only)

Canonical reference: none yet (was health.tsx until its bespoke redesign — see §1d).

┌────────────────────────────────────────────────────────────────────┐
│ Title  [v-badge]                              [action] [action]    │
│ Description / last-update / status info                            │
└────────────────────────────────────────────────────────────────────┘

Traditional title row with description and trailing actions.

MUST NOT use: above any ConfigLayout. See §0b. This includes single-entity Config, multi-entity Config, and any list/form/json layout. Use §1b Rail-anchored instead — the rail owns identity, the body fills the viewport.

Use it when (Monitor only):

  • The body archetype is Monitor (KPI row + grid) and the page has no left rail
  • The description carries genuinely non-redundant context (last refresh, page hierarchy, filter state, version)
  • A HeroBar (§1a) does not fit because the page has more than 4 hero numbers or no clean inline-stat shape

API: <PageHeader title description status actions /> — title + description on left, status badges + action buttons on right.

1d. Bespoke — full custom design

Canonical reference: pages/health.tsx

When a page's content shape demands its own hierarchy (priority-driven sections, dynamic backgrounds tied to status, custom card primitives like HealthStatusRibbon / HealthPriorityCard), the design system gets out of the way. Bespoke pages still respect global concerns (privacy mode, theme, sidebar) but build their own layout from scratch.

Use it when:

  • None of the three patterns above fits without distorting the content
  • The page's information hierarchy is genuinely unique (e.g. severity-driven priority surfaces with secondary audit lists)
  • A bespoke implementation will be clearly better than forcing a fit

Cost: higher LOC, no reuse, no consistency — only justified when content demands it.

Decision table

Page shape Identity strip
Dashboard / overview with ≤4 hero stats HeroBar (home pattern)
Single-entity Config (rail + form + optional json) Rail-anchored (cliproxy pattern, no top chrome)
Multi-entity Config (3-pane: list/form/json) Rail-anchored (cliproxy pattern, no top chrome)
Monitor with a hero viz and no left rail PageHeader + Monitor body
Severity / priority-driven page with custom hierarchy Bespoke (health pattern)
Wizard / login / dialog None — bespoke shell

Rule of thumb: if your page has a left rail at all, it uses §1b Rail-anchored. PageHeader is reserved for the narrow case of a Monitor without a rail.


2. Body archetypes

2a. Config — 3-pane

Canonical reference: pages/cliproxy.tsx

┌──────────┬──────────────────┬──────────┐
│ left     │ form (FormPane)  │ json     │
│ rail     │                  │ (right)  │
└──────────┴──────────────────┴──────────┘

Left rail = ListPane (multi-entity) or SectionRail (single-entity, with IntersectionObserver scroll-spy). Form and JSON panes are middle and right respectively.

<ConfigLayout
  left={<ListPane …/>}            // multi-entity
  // OR
  left={<SectionRail …/>}         // single-entity
  form={<FormPane>…</FormPane>}
  json={<JsonPane data={…} />}
/>

Rules:

  • Save action lives only in FormPane footer
  • Form ↔ json split is user-resizable with persisted ratio (see §0e). Left rail width is fixed.
  • <1024px: collapses to tabs (Browse | Configure | JSON) — divider is hidden in tab mode
  • JsonPane is read-only by default; opt-in editable for cliproxy-style inline editing

2b. Monitor — KPI row + 12-col grid

Canonical reference: none in this PR. Health used to be the reference but went bespoke (§1d). The primitives (MonitorLayout, KpiRow, KpiCard, MonitorGrid, MonitorCard) ship and remain available; first page to genuinely need them becomes the next reference.

┌────────────────────────────────────────┐
│ KpiRow (≤4 hero numbers)               │
├────────────────────────────────────────┤
│ MonitorGrid (12-col):                  │
│   <MonitorCard span={…}/>              │
└────────────────────────────────────────┘
<MonitorLayout kpis={<KpiRow>…</KpiRow>}>
  <MonitorGrid>
    <MonitorCard span={6} variant="terminal" title=…>…</MonitorCard>
  </MonitorGrid>
</MonitorLayout>

Rules:

  • KpiRow only when ≤4 hero numbers; more → group inside the grid
  • One primary viz per page, span ≥8 cols
  • variant="terminal" for live-log / health --watch aesthetics

3. Composing a new page

Config page (default for any provider / profile / account / api management page):

<PageShell>
  <ConfigLayout
    left={<ListPane … />}     // rail owns identity (§1b) — brand, CTA, list, status
    form={<FormPane …/>}      // tabs (if any) live INSIDE this pane's scroll area
    json={<JsonPane …/>}      // optional; same top edge as form (§0c)
  />
</PageShell>

No PageHeader. No description band. The rail header carries the brand and section name; the rail footer carries status / counts.

Dashboard / home page (no rail):

<PageShell>
  <HeroBar … />               // §1a — one row, ≤4 inline stats
  <MonitorLayout … />         // optional grid below
</PageShell>

Monitor page without a rail:

<PageShell>
  <PageHeader title description actions /> {/* §1c — only valid here */}
  <MonitorLayout kpis={<KpiRow … />}>
    <MonitorGrid>…</MonitorGrid>
  </MonitorLayout>
</PageShell>

Target LOC for a new page: ~80 for typical config, ~120 for monitor with hero strip. Target LOC for an outlier rewrite: <400.


4. Anti-patterns (REJECT IN REVIEW)

4a. PageHeader stacked above ConfigLayout

❌ DO NOT:

<PageShell>
  <PageHeader title="API Profiles" description="Premium APIs, local runtimes, custom endpoints" />
  <ConfigLayout left={…} form={…} json={…} />
</PageShell>

The PageHeader steals ~80px from the body, duplicates identity the rail already carries, and pushes the form + json panes below the fold. Use rail-anchored identity (§1b) — move the title into the rail header and remove the description band entirely. See §0b.

4b. Tab bar offsetting sibling panes

❌ DO NOT place a tab bar (e.g. Environment / Info & Usage) at the top of the form pane such that the json pane on the right starts ~40–60px lower. Form and json are siblings sharing one top edge (§0c). Tabs belong inside the form pane's scroll area, not above it as a separate row.

4c. Description bands that repeat the rail

❌ DO NOT add a description below the title that simply restates what the left rail's items already convey ("Premium APIs, local runtimes, custom endpoints" when the rail already lists those entities). If the rail shows it, the band is noise.

4d. Blank vertical band above either column

❌ DO NOT introduce padding, spacing, or a wrapper that produces a >24px blank band between the global topbar and the top of either column. The two columns of a ConfigLayout are flush against the global topbar (§0a). If a wrapper requires that band, the wrapper is wrong.


5. Color & accent usage

The CCS palette is Pampas (warm cream) + Crail (terracotta orange), defined in src/index.css as CSS variables. Never introduce new hues. Every visual decision routes through these tokens:

Token Role When to use
--background (Pampas) Page canvas Outermost shell only
--card Elevated surface FormPane shell, JSON header bar, cards
--card/60, --card/80 Soft elevation FormSection bg, gradient header tails
--muted/20, --muted/30, --muted/40 Pane wash, footer anchor, JSON shell Differentiating sibling panes without a hard border
--accent (Crail) Identity / focus Section dot, top-edge 1px strip, primary CTA, status pill on key chrome
--accent/30, --accent/40 Whisper accent Vertical stripe on FormSection edge, top-edge strip on header bars
--accent/10 Tint background Status pill bg ("editable", "sensitive", "connected")
--muted-foreground Secondary text Field labels, descriptions, hint copy
--destructive Error / danger Form errors, "delete account" buttons, anti-pattern callouts
--ring Focus outlines NEVER override per-component — let focus-visible:ring-* use the token

5a. The 1-accent-dot rule

Each FormSection gets exactly one accent dot (1.5px circle, bg-accent) prefixing its title, and one 2px Crail stripe on the leading edge. These are the only places --accent saturates inside the body.

The header bars (FormPane, JsonPane) carry a 1px accent/40 strip at the top edge — a quiet tie-back to the Save button at the bottom.

If a section needs more attention (e.g. a connected status), use an outline pill with bg-accent/10 + text-accent + border-accent/30, never a filled bg-accent block in the body.

5b. Differentiating sibling panes

Form pane (bg-card) and JSON pane (bg-muted/30) MUST be visually distinct without a hard border line:

  • Form pane = elevated card surface, active editing zone
  • JSON pane = recessed muted surface, read-only inspection zone

Inside the JSON pane, the <pre> block uses bg-card/80 with shadow-inner so the code sits in a subtle embossed well — the user can tell at a glance which side is "yours to edit" and which is "computed for you to read".

5c. Status pills

Use small uppercase pills (10px font, tracking-wider, rounded, 1px border) for status. Two tones:

  • Active / accent: border-accent/40 bg-accent/10 text-accent — for editable, connected, sensitive, default
  • Neutral / muted: border-border bg-muted/60 text-muted-foreground — for read only, unset, disabled

Do not use bg-emerald-* / bg-amber-* / bg-blue-* for status unless the semantic IS the color (e.g. health severity tiers in §1d Bespoke). Default to the palette tokens.

5d. Errors and danger

--destructive is the only red in the system. Use it for:

  • Inline form validation errors (text-destructive for messages, border-destructive/40 on bad inputs)
  • Anti-pattern callouts in docs (the §0 invariants callout in /_styleguide uses it)
  • Truly destructive button actions ("Delete account", "Reset config")

Never tint a description band, an info banner, or a hover state with --destructive.

5e. Quick checklist for a new pane

  • FormSections use bg-card/60 with the leading accent stripe + accent dot before each title
  • FormPane header has a 1px accent/40 top strip and a from-card to-card/70 gradient
  • FormPane footer uses bg-muted/40 to anchor the primary save action
  • JsonPane shell uses bg-muted/30; header uses bg-card/80 with the accent dot + status pill
  • No raw text-blue-500, bg-green-100, etc. — only palette tokens
  • Hover states change opacity of existing tokens, not hue (e.g. before:bg-accent/30 hover:before:bg-accent/70)

5f. Interactive states (the three-tier intensity ladder)

Every interactive element uses the same intensity ladder for accent presence:

State Accent intensity Example
Default accent/30 FormSection leading stripe at rest
Hover / scan accent/70 FormSection stripe on mouse-over (peripheral cue)
Focus / active accent solid + soft ring FormSection on focus-within, ListPane selected row

FormSection implements all three: stripe goes 30 → 70 → 100, plus a soft ring-accent/20 and shadow lift on focus-within. The user always knows which group "owns" their cursor.

ListPane selected row uses the same ladder: 3px Crail stripe on the leading edge + bg-accent/10 row tint + accent-tinted icon and badge. Hover gives a accent/30 stripe preview without the row tint. This visually links the selected entity to the FormSection treatment on the right.

Sticky headers (FormPane.header) include a soft inset bottom shadow so when the body scrolls under, depth is preserved without a hard divider line.

5g. Sensitive fields

Fields whose label matches AUTH_TOKEN|API_KEY|SECRET|PASSWORD|PRIVATE_KEY are automatically rendered as sensitive. The treatment:

  • Lock glyph (lucide-react/Lock) prefixing the label, tinted accent/70
  • "sensitive" status pill on the right of the label row (accent tones per §5c)
  • <input type="password"> by default with a reveal/hide eye-toggle on the right edge
  • Focus ring uses ring-accent/40 + border-accent/50 (vs. neutral ring-ring for ordinary fields) — the only place a default focus ring is overridden

Pages MUST NOT roll their own sensitive-field UI. If the heuristic doesn't match a label, pass sensitive explicitly.

5h. Raw configuration content

JSON content inside JsonPane renders plain — no inline syntax highlighting from this design system. A dedicated JSON viewer with its own color coding will replace the <pre> in a follow-up; until then the chrome of the pane (shell, header strip, accent dot, status pill, embossed code well) carries the §5 treatment, and the content stays uncolored. Pages MUST NOT add inline JSON tints in the meantime.


6. When NOT to use either archetype

These remain bespoke and are out of scope:

  • /login — minimal centered shell
  • Setup wizard — modal overlay
  • Dialogs — Radix Dialog

7. Decisions

See design-decisions.md for the resolved open questions and the v1.1 / v1.2 / v1.3 / v1.4 / v1.5 / v1.6 revision rationale.