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).
This commit is contained in:
Tam Nhu Tran
2026-04-26 13:30:03 -04:00
parent fa34ee82a1
commit 53030891c6
10 changed files with 902 additions and 151 deletions
+93
View File
@@ -52,6 +52,99 @@ After the v1.1 restructure, the `health` page received a separate, focused redes
---
## v1.6 revision (2026-04-26) — content-fit rail (unified envelope, not fixed width)
Live review of the rail-anchored pattern on the API Profiles page surfaced a regression: at the previously-mandated fixed `260px` rail width, the rail header "API Profiles" wrapped onto two lines, the description band wrapped, and the action buttons crowded each other — i.e. the rail was overflowing its own content even though the system mandated that exact width.
**Decision:** the left rail is **unified across pages but content-fit**, not fixed-width. Same primitives, same style treatment (§5), same vertical structure (header → search → list → footer) — but the *width* adapts to its header content within a unified envelope:
- `w-fit` — natural width grows to the largest atomic header element (title + buttons stay on one row)
- `min-w-[240px]` — floor so a sparse rail (e.g. 1-section SectionRail with no badges) doesn't squeeze controls
- `max-w-[360px]` — cap so the rail never dominates the body when an entity label is unusually long; per-item `truncate` inside `ListPane` handles labels beyond that
Pages MUST NOT override this envelope. If a page legitimately needs a wider rail, that is a system-wide envelope change (one PR raises the cap for everyone — uniformity is the point).
**Why not user-resizable?** The form↔json split *is* user-resizable (§0e) because users have different reading-vs-editing preferences mid-task. The rail isn't, because rail content is page-author-controlled and should be sized by the page author at design time. Letting users drag the rail width adds drag affordances inside an identity strip (visual noise) without solving a real recurring need.
**Encoded as §0a** in `design-system.md` (mandatory layout invariant; the prior "fixed minimum width" wording in §0e was rewritten to reference the new envelope). `ConfigLayout` ships the envelope as `<aside className="w-fit min-w-[240px] max-w-[360px] …">` so every Config page inherits the behaviour for free — pages MUST NOT roll their own rail width.
---
## v1.5 revision (2026-04-26) — color & accent treatment for body panes
User feedback during v1.4 styleguide review: the form and JSON panes both rendered as flat white surfaces with no visual hierarchy or accent presence — the panes were technically correct but visually inert and indistinguishable from one another.
**Decision:** apply a structured accent treatment using only the existing Pampas/Crail palette tokens — no new hues introduced. The treatment is encoded into the primitives (`FormPane`, `FormSection`, `JsonPane`) so every Config page inherits the look automatically; pages MUST NOT roll their own bg/border/accent overrides.
**Concrete changes:**
- `FormSection` gains a 2px Crail leading-edge stripe (`before:bg-accent/30`, brightens to `accent/70` on hover) and a 1.5px accent dot prefixing each title — the "1-accent-dot rule"
- `FormPane` header gains a 1px `accent/40` top strip and a `from-card to-card/70` gradient; body wrapper uses `bg-muted/20` so FormSections (`bg-card/60`) read as elevated cards rather than floating; footer uses `bg-muted/40` to anchor the primary save action
- `JsonPane` shell shifts to `bg-muted/30` so the right pane is visually recessed compared to the form pane (active editing surface) → recessed reading surface; header gets the same 1px Crail strip + accent dot + a status pill (`editable` in accent tones, `read only` in neutral tones)
- The inner `<pre>` block in `JsonView` sits inside a `bg-card/80` `shadow-inner` well — gives the code a subtle embossed feel rather than floating on a flat surface
- New **§5 Color & accent usage** in `design-system.md` codifies the rules: token table, the 1-accent-dot rule, sibling-pane differentiation, status pill spec, error/destructive scope, and a 6-point checklist for new panes
**Why it matters:**
- The user is not a designer and asked for guardrails they can follow. §5's checklist + token table is meant to be copy-pastable so future page authors don't need taste — they follow the rules
- Keeping the palette intact (Pampas + Crail only) means brand consistency is preserved while every pane gains depth
- The accent dot/stripe pattern is recognizable: any Config page will now read as "CCS-shaped" at a glance, even before the user reads a single label
**Constraints:**
- No raw Tailwind colors (`bg-blue-*`, `text-emerald-*`, etc.) anywhere except the existing health-priority bespoke page (§1d) and severity-driven semantics
- All elevation done via `bg-card`, `bg-muted/*`, and `bg-card/*` opacity steps — never via shadow alone
- Hover states change opacity of an existing token, never hue
---
## v1.4 revision (2026-04-26) — resizable form / json split
User feedback during v1.3 styleguide review: a single fixed form/json ratio cannot serve both editing (wide form) and reading (wide json) — env blocks like the GLM provider example demand a wide form to enter long values, while debugging effective configuration demands a wide json pane.
**Decision:** the **form (middle) and json (right) panes of `ConfigLayout` are user-resizable** via a draggable horizontal divider. Left rail width stays fixed (rail owns identity and doesn't compete for body width). User's chosen ratio persists in `localStorage` per-page (`ccs.config-layout.<page>.split`).
**Constraints:**
- Minimum widths enforced (form ≥ 360px, json ≥ 320px) — neither pane collapses to unreadable
- Default split: form `flex-1`, json `~3842%` of remaining width
- When `json` prop is omitted, form fills remaining width and no divider renders
- Below `<1024px` the layout collapses to tabs (existing behaviour); divider is irrelevant
- Resize handle is keyboard-accessible (arrow keys move 16px increments, Home/End jump to min/max)
**Encoded as §0e** in `design-system.md` (mandatory layout invariant) plus a rule update in §2a Config archetype. Implementation lives in `ConfigLayout` so every Config page inherits the behaviour for free — pages MUST NOT roll their own resize logic.
**Why an invariant, not a feature flag:** the canonical reference (`cliproxy`) and every migrated provider page show env blocks where one pane is consistently more interesting than the other depending on the task. A fixed ratio is wrong by construction; making the split adjustable is closer to "how IDEs already work" and removes a recurring papercut without expanding the design surface.
---
## v1.3 revision (2026-04-26) — layout invariants and rail-anchored default for Config
Phase 3 + Phase 4 (PR #1105) migrated 12 Config pages to the §1c `PageHeader` pattern stacked above `ConfigLayout`. Live review of the API Profiles page exposed the regression:
- The global topbar (ClaudeKit / Sponsor / Connected / locale / theme) already occupies one horizontal strip
- Adding `PageHeader` ("API Profiles" + description) below it creates a **second strip** that costs ~80px of vertical real estate
- The form pane's tab bar (`Environment / Info & Usage`) pushes the json pane down by another ~40px, so the right column starts well below the left
- Net effect: a large blank L-shaped band wraps the body, the canonical `cliproxy` "no top chrome, body fills viewport" feel is lost, and identity is duplicated between PageHeader and the rail
**Resolution:**
1. New mandatory **§0 Layout invariants** in `design-system.md` — two-column shell, full viewport height, no second horizontal strip, sibling panes share one top edge, `cliproxy.tsx` is the canonical Config reference
2. `§1c PageHeader` is **explicitly disallowed** above any `ConfigLayout`. It is reserved for Monitor pages with no left rail
3. Decision table updated: **all** Config pages (single-entity AND multi-entity) use `§1b Rail-anchored`. The previous row "Single-entity Config OR Monitor → PageHeader" is removed
4. New **§4 Anti-patterns** section documents the four concrete failure modes with rejected code samples (PageHeader-over-ConfigLayout, tab-bar offsetting json pane, redundant description band, blank band above columns)
5. Phase 3 + Phase 4 page migrations that adopted `PageHeader` are now **non-canonical** until refactored to rail-anchored. PR #1105 is held; merging requires a follow-up commit that strips `PageHeader` from every migrated Config page and folds identity into the rail
**Why:** the canonical reference (`cliproxy`) already proved rail-anchored works for dense provider configs. Phase 3 forgot the bottom-up principle from v1.1 ("the design system is restructured around three identity-strip patterns extracted from the existing canonical references") and re-imposed top-down chrome on pages whose left rail already carries identity. v1.3 reasserts the principle and encodes the layout invariants so the rule cannot drift again silently.
**Side effects:**
- `PageHeader` loses its remaining canonical references (Phase 3+4 pages) until a Monitor-without-rail page adopts it
- The `/_styleguide` Intro is updated to surface §0 invariants prominently
- Phase 3 + Phase 4 retrospective report should be filed in `plans/reports/` documenting which pages need rail-anchor refactor and the LOC delta
---
## How to revisit
If a decision turns out wrong in practice, update this doc and bump the affected primitive — don't silently drift. Each row above should be appended with a "Revised: <date> · <reason>" line if changed.
+210 -12
View File
@@ -8,6 +8,49 @@ Some pages legitimately need a bespoke design (the redesigned `health` page is t
---
## 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.
@@ -61,7 +104,7 @@ Page identity (brand + page-level CTA + status) lives **inside the left rail**.
- 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
### 1c. `PageHeader` — title-row chrome (Monitor-only)
**Canonical reference:** none yet (was `health.tsx` until its bespoke redesign — see §1d).
@@ -74,10 +117,12 @@ Page identity (brand + page-level CTA + status) lives **inside the left rail**.
Traditional title row with description and trailing actions.
**Use it when:**
- The page does NOT fit either canonical hero
**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)
- Body archetype below benefits from a clear identity strip
- 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.
@@ -99,11 +144,14 @@ When a page's content shape demands its own hierarchy (priority-driven sections,
| 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) |
| Single-entity Config OR Monitor with a real hero viz | **PageHeader** + body archetype |
| 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
@@ -133,7 +181,8 @@ Left rail = `ListPane` (multi-entity) or `SectionRail` (single-entity, with `Int
**Rules:**
- Save action lives **only** in `FormPane` footer
- `<1024px`: collapses to tabs (Browse | Configure | JSON)
- 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
@@ -166,11 +215,37 @@ Left rail = `ListPane` (multi-entity) or `SectionRail` (single-entity, with `Int
## 3. Composing a new page
**Config page (default for any provider / profile / account / api management page):**
```tsx
// Example: a new dashboard-style page
<PageShell>
<HeroBar /> {/* or PageHeader, or rail-anchored identity */}
<MonitorLayout /> {/* or ConfigLayout */}
<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):**
```tsx
<PageShell>
<HeroBar /> // §1a — one row, ≤4 inline stats
<MonitorLayout /> // optional grid below
</PageShell>
```
**Monitor page without a rail:**
```tsx
<PageShell>
<PageHeader title description actions /> {/* §1c — only valid here */}
<MonitorLayout kpis={<KpiRow />}>
<MonitorGrid></MonitorGrid>
</MonitorLayout>
</PageShell>
```
@@ -178,7 +253,130 @@ Target LOC for a new page: **~80** for typical config, **~120** for monitor with
---
## 4. When NOT to use either archetype
## 4. Anti-patterns (REJECT IN REVIEW)
### 4a. `PageHeader` stacked above `ConfigLayout`
❌ DO NOT:
```tsx
<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 ~4060px 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
@@ -187,6 +385,6 @@ These remain bespoke and are out of scope:
---
## 5. Decisions
## 7. Decisions
See [`design-decisions.md`](./design-decisions.md) for the resolved open questions and the v1.1 revision rationale.
See [`design-decisions.md`](./design-decisions.md) for the resolved open questions and the v1.1 / v1.2 / v1.3 / v1.4 / v1.5 / v1.6 revision rationale.
@@ -1,4 +1,6 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { GripVertical } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
@@ -34,21 +36,36 @@ interface ConfigLayoutProps {
form: ReactNode;
/** Right pane: raw JSON / effective config. Omit to hide. */
json?: ReactNode;
/**
* Persistence key for the form↔json split ratio (per §0e). Use a stable
* per-page id like "config-layout.cliproxy". Falls back to a global key.
*/
storageKey?: string;
className?: string;
}
/**
* ConfigLayout - Strict 3-pane shell for every Config archetype page.
*
* - >=1024px: 3-column grid (left 260px / form flex / json 360px)
* - >=1024px: rail (260px fixed) + resizable form/json split (§0e)
* - <1024px: tabs (left | form | json)
*
* Single component, prop-controlled left rail. The contract:
* - <ConfigLayout left={<ListPane …/>} …/> // multi-entity
* - <ConfigLayout left={<SectionRail …/>} …/> // single-entity
* - <ConfigLayout …/> // no rail
*
* The form↔json divider is user-draggable (react-resizable-panels). The
* chosen ratio persists in localStorage via `autoSaveId={storageKey}`. Min
* sizes prevent either pane from collapsing to unreadable.
*/
export function ConfigLayout({ left, form, json, className }: ConfigLayoutProps) {
export function ConfigLayout({
left,
form,
json,
storageKey = 'ccs.config-layout',
className,
}: ConfigLayoutProps) {
const isDesktop = useIsDesktop();
// CRITICAL: render exactly one layout at a time. Rendering both and
@@ -58,23 +75,30 @@ export function ConfigLayout({ left, form, json, className }: ConfigLayoutProps)
// copy first on mobile, breaking the rail entirely.
if (isDesktop) {
return (
<div
className={cn(
'grid min-h-0 flex-1 gap-4 p-4',
left && json && 'grid-cols-[260px_minmax(0,1fr)_360px]',
left && !json && 'grid-cols-[260px_minmax(0,1fr)]',
!left && json && 'grid-cols-[minmax(0,1fr)_360px]',
!left && !json && 'grid-cols-1',
className
)}
>
<div className={cn('flex min-h-0 flex-1 gap-4 p-4', className)}>
{left && (
<aside className="min-w-0 overflow-hidden rounded-xl border bg-card">{left}</aside>
)}
<main className="min-w-0 overflow-hidden rounded-xl border bg-card">{form}</main>
{json && (
<aside className="min-w-0 overflow-hidden rounded-xl border bg-card">{json}</aside>
// §0a: rail width is content-fit within a unified envelope.
// Floor (min-w 240px) prevents the header from squeezing controls
// into a wrap. Cap (max-w 360px) prevents the rail from dominating
// the body when entity labels are unusually long; per-item
// truncation inside ListPane handles overflow beyond that.
// `w-fit` lets the rail expand to the largest atomic header element
// (title + buttons) so titles never break across two lines.
<aside className="w-fit min-w-[240px] max-w-[360px] shrink-0 overflow-hidden rounded-xl border bg-card">
{left}
</aside>
)}
<PanelGroup direction="horizontal" autoSaveId={storageKey} className="min-w-0 flex-1">
<Panel defaultSize={json ? 45 : 100} minSize={json ? 25 : 100} order={1}>
<main className="h-full overflow-hidden rounded-xl border bg-card">{form}</main>
</Panel>
{json && <ResizeDivider />}
{json && (
<Panel defaultSize={55} minSize={25} order={2}>
<aside className="h-full overflow-hidden rounded-xl border bg-card">{json}</aside>
</Panel>
)}
</PanelGroup>
</div>
);
}
@@ -82,6 +106,42 @@ export function ConfigLayout({ left, form, json, className }: ConfigLayoutProps)
return <MobileTabs left={left} form={form} json={json} className={className} />;
}
/**
* ResizeDivider - draggable handle between form and json panes (§0e).
*
* Keyboard accessible (arrow keys move 16px increments per
* react-resizable-panels default). Visual: thin 8px hot zone with a 1px
* vertical line and a centered grip glyph that brightens on hover/drag.
*/
function ResizeDivider() {
return (
<PanelResizeHandle
className={cn(
'group relative mx-1 flex w-2 shrink-0 items-center justify-center',
'outline-none focus-visible:ring-2 focus-visible:ring-ring rounded'
)}
aria-label="Resize form and JSON panes"
>
{/* Thin vertical line */}
<div
className={cn(
'h-full w-px bg-border transition-colors',
'group-hover:bg-accent group-data-[resize-handle-state=drag]:bg-accent'
)}
/>
{/* Grip glyph appears on hover/drag */}
<div
className={cn(
'absolute flex h-8 w-3 items-center justify-center rounded border bg-background opacity-0 shadow-sm transition-opacity',
'group-hover:opacity-100 group-data-[resize-handle-state=drag]:opacity-100'
)}
>
<GripVertical className="size-3 text-muted-foreground" />
</div>
</PanelResizeHandle>
);
}
function MobileTabs({
left,
form,
+15 -4
View File
@@ -19,15 +19,26 @@ interface FormPaneProps {
*/
export function FormPane({ header, children, footer, className }: FormPaneProps) {
return (
<div className={cn('flex h-full flex-col', className)}>
<div className={cn('flex h-full flex-col bg-card', className)}>
{header && (
<div className="flex shrink-0 items-center gap-2 border-b bg-card px-5 py-3">{header}</div>
// Sticky entity header. 1px accent strip on the top edge nods to the
// page's primary action (Save) which lives at the bottom — same accent.
// The bottom edge has a soft inset shadow so when the body scrolls
// under, the header reads as "elevated" rather than flat.
<div className="relative flex shrink-0 items-center gap-2 border-b bg-gradient-to-b from-card to-card/70 px-5 py-3 shadow-[0_1px_0_oklch(0_0_0/0.04),0_4px_8px_-4px_oklch(0_0_0/0.06)]">
<span aria-hidden className="absolute inset-x-0 top-0 h-px bg-accent/40" />
{header}
</div>
)}
<ScrollArea className="flex-1">
<div className="space-y-4 p-5">{children}</div>
{/* Body uses a faint muted wash so FormSections (bg-card) read as
elevated cards. Without this they would float on a same-color shell. */}
<div className="space-y-4 bg-muted/20 p-5">{children}</div>
</ScrollArea>
{footer && (
<div className="flex shrink-0 items-center gap-2 border-t bg-card/80 px-5 py-3 backdrop-blur">
// Footer anchors the primary save action; muted/40 shadow pulls focus
// downward and keeps the action button visible while the body scrolls.
<div className="flex shrink-0 items-center gap-2 border-t bg-muted/40 px-5 py-3 backdrop-blur">
{footer}
</div>
)}
@@ -29,12 +29,32 @@ export function FormSection({
return (
<section
id={id}
className={cn('scroll-mt-4 rounded-lg border bg-background/40 p-4', className)}
className={cn(
'group/section relative scroll-mt-4 overflow-hidden rounded-lg border bg-card/60 p-4 pl-5',
// Crail accent stripe on the leading edge. Three-state intensity:
// - default: 30% (visible but quiet)
// - hover: 70% (scan affordance)
// - focus-within: 100% solid + soft Crail ring on the section itself,
// so the user always knows which group "owns" their cursor.
'before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-accent/30 before:transition-all',
'transition-all duration-200 hover:bg-card/80 hover:before:bg-accent/70',
'focus-within:bg-card focus-within:shadow-sm focus-within:before:w-[3px] focus-within:before:bg-accent',
'focus-within:ring-1 focus-within:ring-accent/20',
className
)}
>
<header className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold tracking-tight">{title}</h3>
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
<h3 className="flex items-center gap-2 text-sm font-semibold tracking-tight">
<span
aria-hidden
className="size-1.5 shrink-0 rounded-full bg-accent transition-transform group-hover/section:scale-125"
/>
{title}
</h3>
{description && (
<p className="mt-0.5 pl-3.5 text-xs text-muted-foreground">{description}</p>
)}
</div>
{actions && <div className="shrink-0">{actions}</div>}
</header>
+28 -6
View File
@@ -49,11 +49,29 @@ export function JsonPane({
hasTabs && tabs.some((t) => t.id === selectedTabId) ? selectedTabId : (tabs?.[0]?.id ?? 'data');
return (
<div className={cn('flex h-full flex-col', className)}>
<header className="flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h3>
// Outer shell: muted wash differentiates the json pane from the form pane
// (bg-card) so the two sibling panes are visually distinct without a
// hard divider line. Maintains the Pampas/Crail palette — no new colors.
<div className={cn('flex h-full flex-col bg-muted/30', className)}>
<header className="relative flex shrink-0 items-center justify-between gap-2 border-b bg-card/80 px-4 py-2.5 backdrop-blur">
{/* 1px Crail strip at the top edge — visually links to FormPane's header strip. */}
<span aria-hidden className="absolute inset-x-0 top-0 h-px bg-accent/40" />
<div className="flex items-center gap-2">
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-accent" />
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h3>
<span
className={cn(
'rounded border px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider',
editable
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-muted/60 text-muted-foreground'
)}
>
{editable ? 'editable' : 'read only'}
</span>
</div>
<div className="flex items-center gap-1">
<CopyButton
value={
@@ -117,7 +135,11 @@ function JsonView({ data, editable, onChange }: JsonViewProps) {
return (
<ScrollArea className="h-full">
<pre className="whitespace-pre p-3 font-mono text-xs leading-relaxed text-foreground">
{/* Inner card on top of the muted shell — gives the json a subtle
embossed feel rather than floating on a flat surface. JSON content
renders plain; a dedicated JSON viewer (with its own color coding)
will replace this <pre> in a follow-up. */}
<pre className="m-3 whitespace-pre rounded-md border bg-card/80 p-3 font-mono text-xs leading-relaxed text-foreground shadow-inner">
{text}
</pre>
</ScrollArea>
+35 -6
View File
@@ -63,17 +63,46 @@ export function ListPane({
type="button"
onClick={() => onSelect(item.id)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors',
'group/item relative flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-all duration-150',
// Crail accent leading-edge stripe on the selected row —
// matches the FormSection treatment so the "active entity"
// visually links to its form on the right.
'before:absolute before:inset-y-1 before:left-0 before:w-[3px] before:rounded-r before:bg-transparent before:transition-colors',
selected
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50 hover:text-accent-foreground'
? 'bg-accent/10 text-foreground before:bg-accent'
: 'hover:bg-muted/60 hover:before:bg-accent/30'
)}
aria-current={selected ? 'true' : undefined}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.icon && (
<span
className={cn(
'shrink-0 transition-colors',
selected
? 'text-accent'
: 'text-muted-foreground group-hover/item:text-foreground'
)}
>
{item.icon}
</span>
)}
<span
className={cn(
'min-w-0 flex-1 truncate transition-colors',
selected && 'font-medium'
)}
>
{item.label}
</span>
{item.badge && (
<span className="shrink-0 text-xs text-muted-foreground">{item.badge}</span>
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums transition-colors',
selected ? 'bg-accent/15 text-accent' : 'bg-muted/60 text-muted-foreground'
)}
>
{item.badge}
</span>
)}
</button>
</li>
+56
View File
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { Eye, EyeOff, Lock } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
export function Field({
label,
defaultValue,
sensitive,
}: {
label: string;
defaultValue?: string;
sensitive?: boolean;
}) {
const [revealed, setRevealed] = useState(false);
// Heuristic: any *AUTH_TOKEN* / *_KEY / *_SECRET label is sensitive by default.
const isSensitive = sensitive ?? /AUTH_TOKEN|API_KEY|SECRET|PASSWORD|PRIVATE_KEY/i.test(label);
return (
<div className="group/field space-y-1.5">
<div className="flex items-center justify-between gap-2">
<Label className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{isSensitive && <Lock className="size-3 text-accent/70" />}
{label}
</Label>
{isSensitive && (
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-accent">
sensitive
</span>
)}
</div>
<div className="relative">
<Input
type={isSensitive && !revealed ? 'password' : 'text'}
defaultValue={defaultValue}
className={cn(
'font-mono text-sm transition-all',
'focus-visible:ring-1 focus-visible:ring-accent/40 focus-visible:border-accent/50',
isSensitive && 'pr-9'
)}
/>
{isSensitive && (
<button
type="button"
onClick={() => setRevealed((v) => !v)}
className="absolute right-1.5 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-accent"
aria-label={revealed ? 'Hide value' : 'Reveal value'}
>
{revealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</button>
)}
</div>
</div>
);
}
+54 -14
View File
@@ -1,30 +1,70 @@
import { useState } from 'react';
import { Eye, EyeOff, Lock } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Eye, EyeOff } from 'lucide-react';
import { cn } from '@/lib/utils';
interface MaskedInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
/**
* Hide the "sensitive" pill on the label row. Defaults to false (pill shown).
* Use only when the surrounding context already signals sensitivity.
*/
hideSensitivePill?: boolean;
}
export function MaskedInput({ label, ...props }: MaskedInputProps) {
const [visible, setVisible] = useState(false);
/**
* MaskedInput - Sensitive text input per design-system.md §5g.
*
* Treatment: Lock glyph prefixing the label · "sensitive" accent pill on the
* label row · <input type="password"> by default with a reveal/hide eye
* toggle on the right edge · accent focus ring (the only place the default
* focus ring is overridden).
*
* Consumers: API token / OAuth secret / WebSearch API key / env-block secret
* fields across cliproxy / accounts / settings / profile editor.
*/
export function MaskedInput({
label,
hideSensitivePill = false,
className,
...props
}: MaskedInputProps) {
const [revealed, setRevealed] = useState(false);
return (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
<div className="group/masked space-y-1.5">
{label && (
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-1.5 text-sm font-medium">
<Lock aria-hidden className="size-3 text-accent/70" />
{label}
</label>
{!hideSensitivePill && (
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-accent">
sensitive
</span>
)}
</div>
)}
<div className="relative">
<Input type={visible ? 'text' : 'password'} className="pr-10 font-mono" {...props} />
<Button
<Input
type={revealed ? 'text' : 'password'}
className={cn(
'pr-9 font-mono transition-all',
'focus-visible:ring-1 focus-visible:ring-accent/40 focus-visible:border-accent/50',
className
)}
{...props}
/>
<button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setVisible(!visible)}
onClick={() => setRevealed((v) => !v)}
tabIndex={-1}
aria-label={revealed ? 'Hide value' : 'Reveal value'}
className="absolute right-1.5 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-accent"
>
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
{revealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</button>
</div>
</div>
);
+311 -89
View File
@@ -24,8 +24,6 @@ import {
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { PageShell, PageHeader, EmptyState, ErrorState } from '@/components/page-shell';
@@ -46,6 +44,7 @@ import {
KpiRow,
KpiCard,
} from '@/components/monitor-layout';
import { Field } from '@/components/forms/field';
const DEMO_PROVIDERS: ListPaneItem[] = [
{ id: 'provider-a', label: 'Provider A', badge: '14', icon: <Zap className="size-3.5" /> },
@@ -64,11 +63,25 @@ const DEMO_SECTIONS: SectionRailItem[] = [
];
const DEMO_CONFIG = {
endpoint: 'https://example.local:8317',
strategy: 'weighted-round-robin',
accounts: 14,
failover: ['provider-b', 'provider-c'],
timeout_ms: 30000,
env: {
ANTHROPIC_BASE_URL: 'https://api.example.local/anthropic',
ANTHROPIC_AUTH_TOKEN: '••••••••••••••••••••••••',
API_TIMEOUT_MS: '3000000',
ANTHROPIC_MODEL: 'demo-1.0',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'demo-1.0',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'demo-1',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'demo-0.5-air',
DISABLE_TELEMETRY: '1',
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
},
routing: {
strategy: 'weighted-round-robin',
failover: ['provider-b', 'provider-c'],
},
permissions: {
defaultMode: 'bypassPermissions',
},
};
export function StyleguidePage() {
@@ -91,21 +104,21 @@ export function StyleguidePage() {
</PrimitiveSection>
<PrimitiveSection
title="1c. PageHeader — title + description + actions (health pattern)"
title="1c. PageHeader — Monitor-only chrome (NEVER above ConfigLayout)"
anchor="page-header"
>
<DemoPageHeader />
</PrimitiveSection>
<PrimitiveSection
title="2a. Config archetype — multi-entity (ListPane)"
title="2a. Config archetype — multi-entity (rail-anchored, no top chrome)"
anchor="config-multi"
>
<DemoConfigMulti />
</PrimitiveSection>
<PrimitiveSection
title="2b. Config archetype — single-entity (SectionRail)"
title="2b. Config archetype — single-entity (rail-anchored, no top chrome)"
anchor="config-single"
>
<DemoConfigSingle />
@@ -148,7 +161,7 @@ export function StyleguidePage() {
function Intro() {
return (
<header className="mx-auto max-w-4xl space-y-3 text-center">
<header className="mx-auto max-w-4xl space-y-4 text-center">
<Badge variant="outline" className="font-mono">
DEV ONLY · /_styleguide
</Badge>
@@ -156,13 +169,48 @@ function Intro() {
<p className="text-muted-foreground">
Three identity-strip patterns <strong>HeroBar</strong> (one-row dense, see <code>/</code>{' '}
home page), <strong>rail-anchored</strong> (no top chrome, see <code>/cliproxy</code>), and{' '}
<strong>PageHeader</strong> (title + description + actions, see <code>/health</code>) and
two body archetypes: <strong>Config</strong> (3-pane: list / form / JSON) and{' '}
<strong>Monitor</strong> (KPI row + grid). Pick the identity strip that matches your
page&apos;s shape; pick the body archetype your content needs.
<strong>PageHeader</strong> (Monitor-only) and two body archetypes:{' '}
<strong>Config</strong> (rail + form + optional JSON) and <strong>Monitor</strong> (KPI row
+ grid).
</p>
<div className="mx-auto max-w-3xl rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-left text-sm">
<p className="mb-2 font-semibold text-destructive">§0 Layout invariants never violate</p>
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
<li>
Config pages are a <strong>strict two-column shell</strong>: largest pane on the left
(rail / list owns identity), main content on the right. Both columns flush against the
global topbar, filling the viewport.
</li>
<li>
<strong>No second horizontal strip</strong> below the global topbar. No{' '}
<code>PageHeader</code>, breadcrumb row, description band, or KPI ribbon above a{' '}
<code>ConfigLayout</code>.
</li>
<li>
Form and JSON panes are <strong>siblings sharing one top edge</strong>. Tab bars live
inside the form pane&apos;s scroll area, never as a sibling row that offsets the JSON
pane.
</li>
<li>
<code>pages/cliproxy.tsx</code> is the canonical Config reference. When a Config page
disagrees with it on layout shape, the page is wrong.
</li>
<li>
The <strong>left rail is content-fit within a unified envelope</strong> (
<code>w-fit min-w-[240px] max-w-[360px]</code>). Same primitives, same treatment
everywhere; width adapts to header content so titles never wrap. Pages MUST NOT override
this envelope.
</li>
<li>
The <strong>form JSON split is user-resizable</strong> via a draggable divider; the
rail itself is NOT user-resizable. Min widths: form 360px, json 320px. Ratio
persists in <code>localStorage</code> per-page.
</li>
</ul>
</div>
<p className="text-xs text-muted-foreground">
See <code>ui/docs/design-system.md</code> for the decision table.
Full spec: <code>ui/docs/design-system.md</code> · revision history:{' '}
<code>ui/docs/design-decisions.md</code> (current: v1.6, 2026-04-26). Color rules: §5.
</p>
</header>
);
@@ -352,56 +400,167 @@ function DemoRailAnchored() {
<div className="flex h-[640px] flex-col">
{/* NOTE: NO PageHeader at the top. Identity is inside the rail. */}
<ConfigLayout
storageKey="styleguide.rail-anchored"
left={railHeader}
form={
<FormPane
header={<p className="text-sm font-semibold">Provider A</p>}
footer={<Button size="sm">Save</Button>}
header={
<div className="flex w-full items-center justify-between">
<div>
<p className="text-sm font-semibold">Provider A</p>
<p className="text-xs text-muted-foreground">
Last modified: 4/12/2026 · 14 accounts · synced 2m ago
</p>
</div>
<Badge variant="outline">connected</Badge>
</div>
}
footer={
<>
<Button size="sm">Save</Button>
<Button size="sm" variant="outline">
Test connection
</Button>
</>
}
>
<FormSection id="general" title="General">
<Field label="Endpoint" defaultValue="https://example.local:8317" />
<FormSection id="endpoint" title="Endpoint" description="Base URL and auth token.">
<Field
label="ANTHROPIC_BASE_URL"
defaultValue="https://api.example.local/anthropic"
/>
<Field label="ANTHROPIC_AUTH_TOKEN" defaultValue="••••••••••••••••••••••" />
<Field label="API_TIMEOUT_MS" defaultValue="3000000" />
</FormSection>
<FormSection id="models" title="Models" description="Routing for Opus/Sonnet/Haiku.">
<Field label="ANTHROPIC_MODEL" defaultValue="demo-1.0" />
<Field label="ANTHROPIC_DEFAULT_OPUS_MODEL" defaultValue="demo-1.0" />
<Field label="ANTHROPIC_DEFAULT_SONNET_MODEL" defaultValue="demo-1" />
<Field label="ANTHROPIC_DEFAULT_HAIKU_MODEL" defaultValue="demo-0.5-air" />
</FormSection>
<FormSection id="advanced" title="Advanced">
<Field label="ANTHROPIC_TEMPERATURE" defaultValue="0.2" />
<Field label="ANTHROPIC_MAX_TOKENS" defaultValue="65536" />
<Field label="DISABLE_TELEMETRY" defaultValue="1" />
</FormSection>
</FormPane>
}
json={<JsonPane title="Effective" data={DEMO_CONFIG} />}
json={<JsonPane title="Raw configuration" data={DEMO_CONFIG} />}
/>
</div>
);
}
// -----------------------------------------------------------------------------
// PageHeader demo (canonical: pages/health.tsx)
// PageHeader demo — MONITOR-ONLY use (§0b / §1c).
//
// Traditional title row with description and trailing actions. Use when the
// page does NOT fit either canonical hero AND the description carries
// non-redundant context (last refresh, page hierarchy, filter state, version).
// PageHeader stacks a second horizontal strip below the global topbar, which
// costs vertical real estate. That cost is acceptable ONLY when the body is a
// Monitor archetype (KPI row + grid) with NO left rail. NEVER place PageHeader
// above a ConfigLayout — the rail owns identity in Config pages.
// -----------------------------------------------------------------------------
function DemoPageHeader() {
const tiles: Array<{
label: string;
value: string;
hint: string;
tone: 'positive' | 'warning' | 'default';
spark: number[];
}> = [
{
label: 'Active',
value: '87',
hint: '▲ 3 vs yesterday',
tone: 'positive',
spark: [62, 70, 68, 74, 80, 82, 85, 87],
},
{
label: 'Requests/24h',
value: '12,481',
hint: '▲ 6.4%',
tone: 'positive',
spark: [40, 55, 48, 60, 70, 75, 82, 90],
},
{
label: 'Errors',
value: '12',
hint: '3 quota · 9 transient',
tone: 'warning',
spark: [4, 6, 3, 8, 10, 7, 9, 12],
},
{
label: 'Uptime',
value: '99.98%',
hint: '30-day window',
tone: 'default',
spark: [98, 99, 99, 99, 100, 100, 99, 100],
},
];
return (
<PageShell>
<PageHeader
title="Demo Page"
description="PageShell + PageHeader provide consistent chrome for every page."
status={<Badge variant="secondary">Running</Badge>}
actions={
<>
<Button variant="outline" size="sm">
Refresh
</Button>
<Button size="sm">
<Plus className="size-3.5" /> New
</Button>
</>
}
/>
<div className="p-6 text-sm text-muted-foreground">Page body renders below the header.</div>
</PageShell>
<div className="flex flex-col">
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-xs text-amber-800 dark:text-amber-300">
<strong>Allowed only above Monitor body without a rail.</strong> Never stack above{' '}
<code>ConfigLayout</code> see §4a anti-pattern.
</div>
<PageShell>
<PageHeader
title="Demo Monitor"
description="Last refresh: 12s ago · 4 sources · viewing live"
status={<Badge variant="secondary">All systems nominal</Badge>}
actions={
<>
<Button variant="outline" size="sm">
<RefreshCcw className="size-3.5" /> Refresh
</Button>
<Button size="sm">
<Plus className="size-3.5" /> New source
</Button>
</>
}
/>
<div className="grid grid-cols-4 gap-3 p-4">
{tiles.map((t) => {
const max = Math.max(...t.spark);
const toneStroke =
t.tone === 'positive'
? 'stroke-emerald-500'
: t.tone === 'warning'
? 'stroke-amber-500'
: 'stroke-foreground/60';
return (
<div key={t.label} className="rounded-lg border bg-card p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{t.label}
</p>
<p className="mt-1 font-mono text-lg font-bold">{t.value}</p>
<p className="text-[10px] text-muted-foreground">{t.hint}</p>
<svg viewBox="0 0 100 24" className="mt-2 h-6 w-full" preserveAspectRatio="none">
<polyline
fill="none"
strokeWidth="1.5"
className={cn(toneStroke)}
points={t.spark
.map((v, i) => `${(i / (t.spark.length - 1)) * 100},${24 - (v / max) * 22}`)
.join(' ')}
/>
</svg>
</div>
);
})}
</div>
</PageShell>
</div>
);
}
// -----------------------------------------------------------------------------
// Config (multi-entity) demo
// Config (multi-entity) demo — rail-anchored, NO top chrome (§0b / §1b).
//
// Identity (brand + page CTA) lives inside ListPane.header. The two columns
// share one top edge flush against the global topbar; the body fills the
// viewport. Canonical reference: pages/cliproxy.tsx.
// -----------------------------------------------------------------------------
function DemoConfigMulti() {
@@ -412,20 +571,31 @@ function DemoConfigMulti() {
String(p.label).toLowerCase().includes(search.toLowerCase())
);
const railHeader = (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Zap className="size-4 text-primary" />
<h2 className="text-sm font-semibold">CLIProxy</h2>
</div>
<Badge variant="outline" className="text-[10px]">
4 providers
</Badge>
</div>
<Button size="sm" className="w-full gap-1.5">
<Plus className="size-3.5" /> New provider
</Button>
</div>
);
return (
<div className="flex h-[640px] flex-col">
<PageHeader
title="CLIProxy"
status={<Badge variant="secondary">Multi-entity demo</Badge>}
actions={
<Button size="sm">
<Plus className="size-3.5" /> New provider
</Button>
}
/>
{/* NO PageHeader above ConfigLayout — see §4a anti-pattern. */}
<ConfigLayout
storageKey="styleguide.config-multi"
left={
<ListPane
header={railHeader}
items={filtered}
selectedId={selectedId}
onSelect={setSelectedId}
@@ -433,9 +603,12 @@ function DemoConfigMulti() {
onSearchChange={setSearch}
searchPlaceholder="Search providers…"
footer={
<Button variant="outline" size="sm" className="w-full">
<Plus className="size-3.5" /> Add provider
</Button>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{filtered.length} shown</span>
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="size-3" /> 1 connected
</span>
</div>
}
/>
}
@@ -459,61 +632,121 @@ function DemoConfigMulti() {
</>
}
>
<FormSection id="general" title="General" description="Endpoint and routing strategy.">
<FormSection id="general" title="General" description="Display and endpoint.">
<Field label="Display name" defaultValue="Provider A" />
<Field label="Endpoint" defaultValue="https://example.local:8317" />
<Field
label="ANTHROPIC_BASE_URL"
defaultValue="https://api.example.local/anthropic"
/>
<Field label="ANTHROPIC_AUTH_TOKEN" defaultValue="••••••••••••••••••••••" />
<Field label="API_TIMEOUT_MS" defaultValue="3000000" />
</FormSection>
<FormSection id="auth" title="Authentication">
<FormSection id="models" title="Models" description="Routing per role.">
<Field label="ANTHROPIC_MODEL" defaultValue="demo-1.0" />
<Field label="ANTHROPIC_DEFAULT_OPUS_MODEL" defaultValue="demo-1.0" />
<Field label="ANTHROPIC_DEFAULT_SONNET_MODEL" defaultValue="demo-1" />
<Field label="ANTHROPIC_DEFAULT_HAIKU_MODEL" defaultValue="demo-0.5-air" />
</FormSection>
<FormSection id="auth" title="Authentication & Routing">
<Field label="Strategy" defaultValue="weighted-round-robin" />
<Field label="Failover chain" defaultValue="provider-b → provider-c" />
</FormSection>
<FormSection id="advanced" title="Advanced">
<Field label="ANTHROPIC_TEMPERATURE" defaultValue="0.2" />
<Field label="ANTHROPIC_MAX_TOKENS" defaultValue="65536" />
<Field label="DISABLE_TELEMETRY" defaultValue="1" />
</FormSection>
</FormPane>
}
json={<JsonPane title="Effective" data={DEMO_CONFIG} />}
json={<JsonPane title="Raw configuration" data={DEMO_CONFIG} />}
/>
</div>
);
}
// -----------------------------------------------------------------------------
// Config (single-entity) demo
// Config (single-entity) demo — rail-anchored, NO top chrome (§0b / §1b).
//
// Identity (brand + version + primary CTA) lives inside SectionRail.header.
// Both columns flush against the global topbar; the form scrolls inside its
// own pane while the rail stays sticky. No PageHeader above the layout.
// -----------------------------------------------------------------------------
function DemoConfigSingle() {
const railHeader = (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Bot className="size-4 text-primary" />
<h2 className="text-sm font-semibold">Cursor</h2>
</div>
<Badge variant="outline" className="text-[10px] font-mono">
v0.42
</Badge>
</div>
<Button size="sm" variant="outline" className="w-full gap-1.5">
<Plus className="size-3.5" /> Open editor
</Button>
</div>
);
return (
<div className="flex h-[680px] flex-col">
<PageHeader
title="Cursor"
description="Single-entity config with SectionRail."
status={<Badge variant="secondary">v0.42 · connected</Badge>}
actions={
<Button size="sm">
<Plus className="size-3.5" /> Open editor
</Button>
}
/>
{/* NO PageHeader above ConfigLayout — see §4a anti-pattern. */}
<ConfigLayout
left={<SectionRail sections={DEMO_SECTIONS} />}
storageKey="styleguide.config-single"
left={<SectionRail header={railHeader} sections={DEMO_SECTIONS} />}
form={
<FormPane footer={<Button size="sm">Save configuration</Button>}>
<FormSection id="general" title="General" description="Top-level identity.">
<Field label="Endpoint" defaultValue="https://example.local:8317" />
<FormPane
header={
<div className="flex w-full items-center justify-between">
<div>
<p className="text-sm font-semibold">Cursor IDE</p>
<p className="text-xs text-muted-foreground">
Last modified: 4/12/2026 · workspace synced
</p>
</div>
<Badge variant="outline">connected</Badge>
</div>
}
footer={
<>
<Button size="sm">Save configuration</Button>
<Button size="sm" variant="outline">
Open editor
</Button>
</>
}
>
<FormSection id="general" title="General" description="Endpoint and identity.">
<Field
label="ANTHROPIC_BASE_URL"
defaultValue="https://api.example.local/anthropic"
/>
<Field label="Default profile" defaultValue="example-profile" />
<Field label="API_TIMEOUT_MS" defaultValue="3000000" />
</FormSection>
<FormSection id="auth" title="Authentication">
<Field label="Strategy" defaultValue="oauth" />
<Field label="ANTHROPIC_AUTH_TOKEN" defaultValue="••••••••••••••••••••••" />
</FormSection>
<FormSection id="routing" title="Routing">
<Field label="Strategy" defaultValue="weighted-round-robin" />
<Field label="Failover chain" defaultValue="provider-b → provider-c" />
</FormSection>
<FormSection id="models" title="Models">
<Field label="Default model" defaultValue="model-x" />
<Field label="ANTHROPIC_MODEL" defaultValue="demo-1.0" />
<Field label="ANTHROPIC_DEFAULT_OPUS_MODEL" defaultValue="demo-1.0" />
<Field label="ANTHROPIC_DEFAULT_SONNET_MODEL" defaultValue="demo-1" />
<Field label="ANTHROPIC_DEFAULT_HAIKU_MODEL" defaultValue="demo-0.5-air" />
</FormSection>
<FormSection id="tools" title="Tools & MCP">
<Field label="MCP endpoint" defaultValue="(none)" />
</FormSection>
<FormSection id="advanced" title="Advanced">
<Field label="Timeout (ms)" defaultValue="30000" />
<Field label="ANTHROPIC_TEMPERATURE" defaultValue="0.2" />
<Field label="ANTHROPIC_MAX_TOKENS" defaultValue="65536" />
<Field label="DISABLE_TELEMETRY" defaultValue="1" />
</FormSection>
</FormPane>
}
@@ -651,16 +884,5 @@ function DemoMonitor() {
}
// -----------------------------------------------------------------------------
// tiny field helper
// tiny field helper (extracted to shared component)
// -----------------------------------------------------------------------------
function Field({ label, defaultValue }: { label: string; defaultValue: string }) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</Label>
<Input defaultValue={defaultValue} className="font-mono text-sm" />
</div>
);
}