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-preview.html b/ui/docs/design-system-preview.html new file mode 100644 index 00000000..2dad471f --- /dev/null +++ b/ui/docs/design-system-preview.html @@ -0,0 +1,122 @@ + + +CCS Dashboard Design System — Phase 1 Preview + +
+
+
+

CCS Dashboard Design System — Phase 1

+

Force-UI visual review of the foundations PR. Two locked archetypes (Config 3-pane and Monitor 12-col grid) wrapped by a shared PageShell. Every page going forward picks one.

+
+
+ PR #1088 + Privacy mode ON + /_styleguide · DEV ONLY +
+
+ +

1 · Shared shell

+
PageShell +├─ PageHeader (title · description · status · actions) +└─ PageBody + ├─ ConfigLayout ListPane │ FormPane │ JsonPane ← config archetype + └─ MonitorLayout KpiRow + MonitorGrid(MonitorCard…) ← monitor archetype
+ +

2 · /_styleguide overview

+

Dev-only route gated by import.meta.env.DEV. Renders every primitive plus composed Config + Monitor demos. Privacy toggle on; all data anonymized as Provider A/B/C.

+styleguide overview + +

3 · Config archetype — multi-entity (ListPane)

+

Used by cliproxy, accounts, providers. Left rail = list of entities. Save lives only in FormPane footer.

+Config — multi-entity + +

4 · Config archetype — single-entity (SectionRail)

+

Used by codex, copilot, cursor, droid, claude-extension. Same 3-pane shell — left rail swaps to anchor nav with IntersectionObserver scroll-spy. Solves the long-scroll problem on big single-entity pages.

+Config — single-entity + +

5 · Monitor archetype

+

Used by home, analytics, health, logs. KPI row + 12-col responsive grid. MonitorCard variant="terminal" preserves the ccs health --watch aesthetic as opt-in.

+Monitor archetype + +

6 · Primitive map

+
+

Page shell

components/page-shell/ +├─ page-shell.tsx +├─ page-header.tsx +├─ empty-state.tsx +└─ error-state.tsx
+

Config (3-pane)

components/config-layout/ +├─ config-layout.tsx +├─ list-pane.tsx +├─ section-rail.tsx +├─ form-pane.tsx +├─ form-section.tsx +└─ json-pane.tsx
+

Monitor (grid)

components/monitor-layout/ +├─ monitor-layout.tsx +├─ kpi.tsx (KpiRow + KpiCard) +└─ monitor-grid.tsx (MonitorGrid + MonitorCard)
+

Out of scope

login · setup wizard · dialogs + +These keep their own minimal +shells. Don't force them into +an archetype.
+
+ +

7 · Locked decisions

+ + + + + + + + + +
#QuestionDecision
1Storybook vs in-appIn-app /_styleguide — zero-config, lives in repo, dev-only
2Archetype B nameMonitor (Dashboard is the whole product)
3JsonPane editabilityRead-only by default, opt-in editable prop
4Health terminal aestheticKeep as variant="terminal"
5i18n namespacePer-page (pages.<name>.*) + shared (common.*)
6SectionRail activationScroll-spy via IntersectionObserver
+ +

8 · How to verify locally

+
+cd ui && bun run dev then visit http://localhost:5173/_styleguide
+The route only exists when import.meta.env.DEV === true; production builds exclude it entirely. +
+ +

9 · What lands next

+ + + + + + +
PhaseScopePRs
2Reference migrations: cliproxy, home, health1 PR
3Opportunistic conversion: codex, copilot, accounts, api, shared, analytics, logs, updates~8 PRs (smallest first)
4Outlier rewrites: droid (749) → claude-extension (1118) → cursor (1413) → cliproxy-ai-providers (~1700)4 PRs
+ +

+Source: plans/260425-1132-ccs-dashboard-design-system/ · Brainstorm: brainstorm-260425-1115-ccs-dashboard-design-system.md +

+
\ No newline at end of file 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/docs/screenshots/01-styleguide-overview.png b/ui/docs/screenshots/01-styleguide-overview.png new file mode 100644 index 00000000..d9613b7d Binary files /dev/null and b/ui/docs/screenshots/01-styleguide-overview.png differ diff --git a/ui/docs/screenshots/02-config-multi-entity.png b/ui/docs/screenshots/02-config-multi-entity.png new file mode 100644 index 00000000..789e9f20 Binary files /dev/null and b/ui/docs/screenshots/02-config-multi-entity.png differ diff --git a/ui/docs/screenshots/03-config-single-entity.png b/ui/docs/screenshots/03-config-single-entity.png new file mode 100644 index 00000000..848ffab9 Binary files /dev/null and b/ui/docs/screenshots/03-config-single-entity.png differ diff --git a/ui/docs/screenshots/04-monitor-archetype.png b/ui/docs/screenshots/04-monitor-archetype.png new file mode 100644 index 00000000..98d8734a Binary files /dev/null and b/ui/docs/screenshots/04-monitor-archetype.png differ 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..518fd877 --- /dev/null +++ b/ui/src/components/config-layout/config-layout.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; + +const DESKTOP_BREAKPOINT_PX = 1024; + +/** + * Track whether the viewport meets the desktop breakpoint. + * Used to render EITHER the 3-pane grid OR the mobile tabs — never both. + * Rendering both at once duplicates FormSection ids in the DOM, which breaks + * SectionRail scroll-spy (getElementById returns the hidden desktop copy first). + */ +function useIsDesktop(): boolean { + const [isDesktop, setIsDesktop] = useState(() => + typeof window === 'undefined' + ? true // SSR-safe default; harmless on first paint + : window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT_PX}px)`).matches + ); + + useEffect(() => { + const mql = window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT_PX}px)`); + const onChange = (e: MediaQueryListEvent) => setIsDesktop(e.matches); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, []); + + return isDesktop; +} + +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) { + const isDesktop = useIsDesktop(); + + // CRITICAL: render exactly one layout at a time. Rendering both and + // toggling visibility via Tailwind `hidden lg:grid` would mount two copies + // of every FormSection — SectionRail's scroll-spy and click-to-jump use + // document.getElementById() which would resolve to the (hidden) desktop + // copy first on mobile, breaking the rail entirely. + if (isDesktop) { + return ( +
+ {left && ( + + )} +
{form}
+ {json && ( + + )} +
+ ); + } + + return ; +} + +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 [selected, setSelected] = useState(tabs[0]?.id ?? 'form'); + + // Derive the effective active tab during render so a parent toggling `left` + // or `json` (which changes the available tabs) cannot leave us pointing at + // an id that no longer exists. Falls back to the first available tab. + const active = tabs.some((t) => t.id === selected) ? selected : (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..1c48fac7 --- /dev/null +++ b/ui/src/components/config-layout/json-pane.tsx @@ -0,0 +1,125 @@ +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 [selectedTabId, setSelectedTabId] = useState(tabs?.[0]?.id ?? 'data'); + + // Derive the *effective* active tab during render rather than mutating state + // in an effect. If the parent swaps the `tabs` array (e.g. selects a different + // entity), the previous selectedTabId may no longer exist — fall back to the + // first available tab so the pane never shows empty content. + const activeTab = + hasTabs && tabs.some((t) => t.id === selectedTabId) ? selectedTabId : (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) { + // `key={text}` forces React to remount the textarea when the underlying data + // changes (e.g. parent swaps the selected entity). Without this, an + // uncontrolled textarea retains the prior value and onBlur saves stale text. + return ( +