From 64e78f6e69e11ea43ce14baa62cd82ef0c9458c4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 25 Apr 2026 12:16:27 -0400 Subject: [PATCH 1/5] feat(ui): add design system foundations Introduce two locked page archetypes -- Config (3-pane) and Monitor (KPI row + 12-col grid) -- wrapped by shared PageShell + PageHeader. Primitives: - page-shell/: PageShell, PageHeader, EmptyState, ErrorState - config-layout/: ConfigLayout, ListPane, SectionRail, FormPane, FormSection, JsonPane (read-only by default, opt-in editable) - monitor-layout/: MonitorLayout, KpiRow, KpiCard, MonitorGrid, MonitorCard (incl. variant="terminal") Single ConfigLayout component, prop-controlled left rail: - ListPane for multi-entity (cliproxy, accounts, providers) - SectionRail for single-entity (codex, copilot, cursor, droid) with IntersectionObserver scroll-spy - omit for none DEV-ONLY /_styleguide route gated by import.meta.env.DEV. Shows every primitive plus composed Config (multi + single) and Monitor demos using fully anonymized data (Provider A/B/C, fake metrics). Phase 1 of dashboard design system unification. Existing pages untouched this phase -- migrations land in Phase 2+. Locked decisions in ui/docs/design-decisions.md: 1. In-app /_styleguide over Storybook 2. Archetype name: Monitor (not Dashboard) 3. JsonPane read-only by default 4. Health terminal aesthetic kept as variant 5. i18n per-page namespaces 6. SectionRail uses scroll-spy --- ui/docs/design-decisions.md | 18 + ui/docs/design-system.md | 179 +++++++ ui/src/App.tsx | 15 + .../config-layout/config-layout.tsx | 86 ++++ ui/src/components/config-layout/form-pane.tsx | 36 ++ .../components/config-layout/form-section.tsx | 44 ++ ui/src/components/config-layout/index.ts | 6 + ui/src/components/config-layout/json-pane.tsx | 114 +++++ ui/src/components/config-layout/list-pane.tsx | 87 ++++ .../components/config-layout/section-rail.tsx | 110 +++++ ui/src/components/monitor-layout/index.ts | 3 + ui/src/components/monitor-layout/kpi.tsx | 56 +++ .../monitor-layout/monitor-grid.tsx | 150 ++++++ .../monitor-layout/monitor-layout.tsx | 36 ++ ui/src/components/page-shell/empty-state.tsx | 36 ++ ui/src/components/page-shell/error-state.tsx | 33 ++ ui/src/components/page-shell/index.ts | 4 + ui/src/components/page-shell/page-header.tsx | 41 ++ ui/src/components/page-shell/page-shell.tsx | 20 + ui/src/pages/_styleguide.tsx | 439 ++++++++++++++++++ 20 files changed, 1513 insertions(+) create mode 100644 ui/docs/design-decisions.md create mode 100644 ui/docs/design-system.md create mode 100644 ui/src/components/config-layout/config-layout.tsx create mode 100644 ui/src/components/config-layout/form-pane.tsx create mode 100644 ui/src/components/config-layout/form-section.tsx create mode 100644 ui/src/components/config-layout/index.ts create mode 100644 ui/src/components/config-layout/json-pane.tsx create mode 100644 ui/src/components/config-layout/list-pane.tsx create mode 100644 ui/src/components/config-layout/section-rail.tsx create mode 100644 ui/src/components/monitor-layout/index.ts create mode 100644 ui/src/components/monitor-layout/kpi.tsx create mode 100644 ui/src/components/monitor-layout/monitor-grid.tsx create mode 100644 ui/src/components/monitor-layout/monitor-layout.tsx create mode 100644 ui/src/components/page-shell/empty-state.tsx create mode 100644 ui/src/components/page-shell/error-state.tsx create mode 100644 ui/src/components/page-shell/index.ts create mode 100644 ui/src/components/page-shell/page-header.tsx create mode 100644 ui/src/components/page-shell/page-shell.tsx create mode 100644 ui/src/pages/_styleguide.tsx diff --git a/ui/docs/design-decisions.md b/ui/docs/design-decisions.md new file mode 100644 index 00000000..af84664e --- /dev/null +++ b/ui/docs/design-decisions.md @@ -0,0 +1,18 @@ +# Design System — Decisions Log + +The 6 open questions from the brainstorm phase, resolved before implementation. + +| # | Question | Decision | Rationale | +|---|----------|----------|-----------| +| 1 | Storybook vs in-app `/_styleguide` | **In-app `/_styleguide`** | Zero-config, lives in the repo, gated by `import.meta.env.DEV`. Storybook is a heavy second build pipeline for marginal benefit at this scale. | +| 2 | Archetype B name (Monitor vs Dashboard) | **Monitor** | "Dashboard" is the whole product. "Monitor" matches existing health/analytics framing and avoids overloading the term. | +| 3 | JsonPane editability scope | **Read-only by default**, opt-in `editable` prop | Cliproxy is the only page that genuinely needs in-pane editing today. Opt-in keeps the API safe for codex/copilot/cursor (read-only). | +| 4 | Health terminal aesthetic | **Keep** as `MonitorCard variant="terminal"` | Preserves the `ccs health --watch` feel. Implemented as opt-in variant, not a separate primitive. | +| 5 | i18n key namespace convention | **Per-page namespaces** (`pages..*`) + shared (`common.*`) | Current i18n already uses ad-hoc per-page keys; this just formalizes it. Primitives use `common.*` so they're translation-stable across pages. | +| 6 | SectionRail activation | **Scroll-spy** via `IntersectionObserver` | Preserves long-form config feel + shows all validation errors at once. Click-to-switch hides errors in inactive sections, which is worse UX for forms. | + +--- + +## 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: · " line if changed. diff --git a/ui/docs/design-system.md b/ui/docs/design-system.md new file mode 100644 index 00000000..edae68af --- /dev/null +++ b/ui/docs/design-system.md @@ -0,0 +1,179 @@ +# CCS Dashboard Design System + +The dashboard ships **two page archetypes**. Every page picks exactly one. All pages share the same outer chrome via `PageShell` + `PageHeader`. + +> Live preview in dev: `bun run dev` then visit `/_styleguide`. + +--- + +## 1. The shell — every page + +``` +PageShell +├─ PageHeader (title · description · status · actions) +└─ +``` + +```tsx + + + {/* OR */} + +``` + +Header rules: +- `title` is mandatory; `description` optional 1-line subtitle. +- `status` slot: small badges/chips (e.g. "Running · :8317"). +- `actions` slot: button group; **primary action belongs in FormPane footer**, not here, for Config pages. + +--- + +## 2. Config archetype — 3-pane + +Used by every page that configures one or more entities (cliproxy, accounts, codex, copilot, cursor, droid, claude-extension, api, shared, profiles). + +``` +┌──────────┬──────────────────┬──────────┐ +│ left │ form (FormPane) │ json │ +│ rail │ │ (right) │ +└──────────┴──────────────────┴──────────┘ +``` + +One component, prop-controlled left rail: + +```tsx +// Multi-entity (cliproxy, accounts, providers) +} + form={} + json={} +/> + +// Single-entity (codex, copilot, cursor, droid, claude-extension) +} + form={} + json={} +/> + +// No rail (rare — only if page has neither entities to pick nor sections to navigate) +…} json={} /> +``` + +### Rules + +- **Layout**: left 260px / form flex / json 360px on `>=1024px`. +- **<1024px**: collapses to tabs (`Browse | Configure | JSON`). +- **Save action**: lives **only** in `FormPane footer`. Never duplicate in PageHeader. +- **Per-entity actions** (delete, duplicate, sync): ListPane row trailing actions or FormPane header secondary actions — never both. +- **JsonPane is read-only by default**. Pass `editable` prop only on pages that need inline editing (cliproxy is the canonical example). + +### SectionRail + +For single-entity pages. Provides anchor nav with `IntersectionObserver` scroll-spy. Each item must match the `id` of a `` in the FormPane. + +```tsx + + +// FormPane: + + +``` + +--- + +## 3. Monitor archetype — KPI row + 12-col grid + +Used by every page that shows live state (home, analytics, health, logs, future status pages). + +``` +┌────────────────────────────────────────────────┐ +│ KpiRow: [KPI] [KPI] [KPI] [KPI] │ (optional, ≤4 hero numbers) +├────────────────────────────────────────────────┤ +│ MonitorGrid (12-col): │ +│ primary viz │ +│ side widget │ +│ ... │ +│ ... │ +└────────────────────────────────────────────────┘ +``` + +```tsx + + + … + + } +> + + + + + + + +``` + +### Rules + +- **KpiRow** only when there are ≤4 hero numbers. More than 4 → use `MonitorCard`s inside the grid instead. +- **One primary viz per page** — span ≥8 cols. Preserves the "punch" of home and analytics. +- **`variant="terminal"`** = dark monospace card, for live-log / `health --watch` aesthetics. Opt-in only. +- **Spans clamp at 6 cols on tablet**, single column on mobile. + +--- + +## 4. When NOT to use either archetype + +These remain bespoke: +- `/login` — minimal centered shell, no header/grid. +- Setup wizard — modal overlay, not a page. +- Dialogs — radix `Dialog`, not a layout. + +If you find yourself fighting the shell, the answer is usually "this isn't a Config or Monitor page" — talk to the maintainers before inventing a third archetype. + +--- + +## 5. Composing a new page + +The recipe for any new page is: + +```tsx +import { PageShell, PageHeader } from '@/components/page-shell'; +import { ConfigLayout, /*…*/ } from '@/components/config-layout'; + +export function MyPage() { + return ( + + } /> + …} + json={} + /> + + ); +} +``` + +Target LOC for a new page: **~80** for typical config, **~120** for monitor. If your page exceeds 400 LOC it should be split into `pages//` with section files. + +--- + +## 6. Lint / enforcement + +Phase 4 adds a lint rule: every file under `src/pages/*.tsx` (or `src/pages/*/index.tsx`) must import `PageShell`. Until then, code review enforces the contract. + +--- + +## 7. Decisions + +See [`design-decisions.md`](./design-decisions.md) for the resolutions of the 6 open questions from the brainstorm phase. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1ebba72f..caceebea 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -47,6 +47,11 @@ const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage }))); const UpdatesPage = lazy(() => import('@/pages/updates').then((m) => ({ default: m.UpdatesPage }))); +// Dev-only: design system styleguide +const StyleguidePage = import.meta.env.DEV + ? lazy(() => import('@/pages/_styleguide').then((m) => ({ default: m.StyleguidePage }))) + : null; + // Loading fallback for lazy components function PageLoader() { return ( @@ -203,6 +208,16 @@ export default function App() { } /> + {StyleguidePage && ( + }> + + + } + /> + )} diff --git a/ui/src/components/config-layout/config-layout.tsx b/ui/src/components/config-layout/config-layout.tsx new file mode 100644 index 00000000..3534dccb --- /dev/null +++ b/ui/src/components/config-layout/config-layout.tsx @@ -0,0 +1,86 @@ +import { useState, type ReactNode } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; + +interface ConfigLayoutProps { + /** Left rail: for multi-entity, for single-entity, omit for none. */ + left?: ReactNode; + /** Middle pane: form. */ + form: ReactNode; + /** Right pane: raw JSON / effective config. Omit to hide. */ + json?: ReactNode; + className?: string; +} + +/** + * ConfigLayout - Strict 3-pane shell for every Config archetype page. + * + * - >=1024px: 3-column grid (left 260px / form flex / json 360px) + * - <1024px: tabs (left | form | json) + * + * Single component, prop-controlled left rail. The contract: + * - } …/> // multi-entity + * - } …/> // single-entity + * - // no rail + */ +export function ConfigLayout({ left, form, json, className }: ConfigLayoutProps) { + return ( + <> + {/* Desktop: 3-pane grid */} +
+ {left && ( + + )} +
{form}
+ {json && ( + + )} +
+ + {/* Mobile/tablet: tabs */} + + + ); +} + +function MobileTabs({ + left, + form, + json, + className, +}: Pick) { + const tabs = [ + left && { id: 'list', label: 'Browse', node: left }, + { id: 'form', label: 'Configure', node: form }, + json && { id: 'json', label: 'JSON', node: json }, + ].filter(Boolean) as { id: string; label: string; node: ReactNode }[]; + const [active, setActive] = useState(tabs[0]?.id ?? 'form'); + + return ( +
+ + + {tabs.map((t) => ( + + {t.label} + + ))} + + {tabs.map((t) => ( + + {t.node} + + ))} + +
+ ); +} diff --git a/ui/src/components/config-layout/form-pane.tsx b/ui/src/components/config-layout/form-pane.tsx new file mode 100644 index 00000000..6ede0fb6 --- /dev/null +++ b/ui/src/components/config-layout/form-pane.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; + +interface FormPaneProps { + /** Sticky header above the scrolling form body. Typically entity name + secondary actions. */ + header?: ReactNode; + /** Form body — typically a stack of s. */ + children: ReactNode; + /** Footer pinned at bottom. Place primary save action here. */ + footer?: ReactNode; + className?: string; +} + +/** + * FormPane - Middle pane of ConfigLayout. Holds the form. + * + * Layout: optional sticky header, scrolling body, optional sticky footer (for primary actions). + */ +export function FormPane({ header, children, footer, className }: FormPaneProps) { + return ( +
+ {header && ( +
{header}
+ )} + +
{children}
+
+ {footer && ( +
+ {footer} +
+ )} +
+ ); +} diff --git a/ui/src/components/config-layout/form-section.tsx b/ui/src/components/config-layout/form-section.tsx new file mode 100644 index 00000000..fb5d19e1 --- /dev/null +++ b/ui/src/components/config-layout/form-section.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +interface FormSectionProps { + /** Anchor id — must match SectionRail item id for scroll-spy. */ + id: string; + title: ReactNode; + description?: ReactNode; + /** Optional trailing actions in section header. */ + actions?: ReactNode; + children: ReactNode; + className?: string; +} + +/** + * FormSection - Titled card group inside FormPane. + * + * The `id` prop is REQUIRED and drives SectionRail scroll-spy. Each section in a + * single-entity Config page should have a stable, kebab-case id like "general", "auth", "routing". + */ +export function FormSection({ + id, + title, + description, + actions, + children, + className, +}: FormSectionProps) { + return ( +
+
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+
{children}
+
+ ); +} diff --git a/ui/src/components/config-layout/index.ts b/ui/src/components/config-layout/index.ts new file mode 100644 index 00000000..b14c68b0 --- /dev/null +++ b/ui/src/components/config-layout/index.ts @@ -0,0 +1,6 @@ +export { ConfigLayout } from './config-layout'; +export { ListPane, type ListPaneItem } from './list-pane'; +export { SectionRail, type SectionRailItem } from './section-rail'; +export { FormPane } from './form-pane'; +export { FormSection } from './form-section'; +export { JsonPane, type JsonTab } from './json-pane'; diff --git a/ui/src/components/config-layout/json-pane.tsx b/ui/src/components/config-layout/json-pane.tsx new file mode 100644 index 00000000..35fa0414 --- /dev/null +++ b/ui/src/components/config-layout/json-pane.tsx @@ -0,0 +1,114 @@ +import { useMemo, useState, type ReactNode } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { CopyButton } from '@/components/ui/copy-button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; + +export interface JsonTab { + id: string; + label: ReactNode; + /** Object/array to render. Stringified internally. */ + data: unknown; +} + +interface JsonPaneProps { + /** Single source — used when there's only one view. */ + data?: unknown; + /** Multi-tab mode (e.g. Effective | Override | Diff). */ + tabs?: JsonTab[]; + title?: ReactNode; + /** Accept structural edits. Off by default — Phase 1 ships read-only baseline. */ + editable?: boolean; + /** Called when editable=true and user commits a change. */ + onChange?: (next: string) => void; + className?: string; +} + +/** + * JsonPane - Right pane of ConfigLayout. Displays raw / effective configuration. + * + * Read-only by default. Pass `editable` to opt-in to inline editing (cliproxy uses this). + * Single-source via `data` OR multi-tab via `tabs` (e.g. effective / override / diff). + */ +export function JsonPane({ + data, + tabs, + title = 'Configuration', + editable = false, + onChange, + className, +}: JsonPaneProps) { + const hasTabs = tabs && tabs.length > 0; + const [activeTab, setActiveTab] = useState(tabs?.[0]?.id ?? 'data'); + + return ( +
+
+

+ {title} +

+
+ t.id === activeTab)?.data ?? {}, null, 2) + : JSON.stringify(data ?? {}, null, 2) + } + /> +
+
+ + {hasTabs ? ( + + + {tabs.map((t) => ( + + {t.label} + + ))} + + {tabs.map((t) => ( + + + + ))} + + ) : ( + + )} +
+ ); +} + +interface JsonViewProps { + data: unknown; + editable: boolean; + onChange?: (next: string) => void; +} + +function JsonView({ data, editable, onChange }: JsonViewProps) { + const text = useMemo(() => JSON.stringify(data ?? {}, null, 2), [data]); + + if (editable) { + return ( +