diff --git a/ui/docs/design-decisions.md b/ui/docs/design-decisions.md index af84664e..639c2e65 100644 --- a/ui/docs/design-decisions.md +++ b/ui/docs/design-decisions.md @@ -13,6 +13,45 @@ The 6 open questions from the brainstorm phase, resolved before implementation. --- +## v1.1 revision (2026-04-25) — identity-strip patterns + +Phase 2 attempted to migrate `home` and `cliproxy` to a one-size-fits-all `PageShell + PageHeader` chrome. Both regressed on density: + +- **home** had a single-row hero (logo + title + version + 4 inline stats) — splitting it into a stacked PageHeader + KpiRow doubled vertical footprint and lost scannability +- **cliproxy** had identity in the left rail — adding a top PageHeader duplicated branding and stole ~80px from the 3-pane body + +**Resolution:** the design system is restructured around three identity-strip patterns extracted from the existing canonical references: + +| # | Pattern | Reference | When | +|---|---------|-----------|------| +| 7a | `HeroBar` (1-row dense) | `pages/home.tsx` | Dashboard pages with ≤4 hero stats | +| 7b | Rail-anchored identity (no top chrome) | `pages/cliproxy.tsx` | Multi-entity Config pages where rail carries brand | +| 7c | `PageHeader` (current) | `pages/health.tsx` | Pages where description / status info is non-redundant | + +Phase 2's home + cliproxy migrations are reverted. Health stays migrated (Monitor archetype + PageHeader works there). Future page migrations adapt to whichever pattern fits, NOT the other way around. + +**Why bottom-up:** the existing references already proved their patterns work in production. The job of the design system is to formalize what works, not impose what should. + +--- + +## v1.2 revision (2026-04-25) — health redesign and bespoke pattern + +After the v1.1 restructure, the `health` page received a separate, focused redesign per a dedicated handoff brief (`plans/reports/handoff-260425-1417-health-page-redesign.md`). The redesign went bespoke: + +- New domain components: `HealthStatusRibbon`, `HealthPriorityCard`, `HealthPriorityList`, `HealthAuditSection` +- Severity-driven layout: priority surfaces (errors/warnings) at the top with prominent fix affordances, audit accordion below +- Dynamic colored background tied to overall status, glassmorphic accents +- No `PageShell`, no `PageHeader`, no `MonitorLayout` — full custom shell + +**Resolution:** add a fourth identity-strip pattern — **§1d Bespoke** — for pages whose content shape doesn't fit the three primary patterns. The v1.1 row's claim that "health stays migrated (Monitor archetype + PageHeader works there)" no longer holds; corrected here. + +**Side effects:** +- `PageHeader` lost its canonical reference (was health). New reference TBD when another page adopts it. +- `MonitorLayout` lost its canonical reference. Primitives remain available for future Monitor pages. +- `Bespoke` is explicitly an escape hatch — not a default. Code review enforces "you tried the three patterns first." + +--- + ## 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-phase2-preview.html b/ui/docs/design-system-phase2-preview.html new file mode 100644 index 00000000..3f70a0c9 --- /dev/null +++ b/ui/docs/design-system-phase2-preview.html @@ -0,0 +1,86 @@ + + +CCS Dashboard Design System — Phase 2 (final) + +
+
+
+

CCS Dashboard Design System — Phase 2 (final)

+

Phase 2 evolved through three stages: (1) initial migration of home/cliproxy/health to one-size-fits-all chrome — regressed; (2) revert + restructure docs around three identity-strip patterns extracted from canonical references; (3) bespoke health redesign per a dedicated brief, adding a fourth pattern for pages whose content shape demands custom hierarchy.

+
+
+ Phase 2 / 4 + Privacy mode ON +
+
+ +

1 · Final pages in this PR

+ + + + + + +
PageStatePattern
home.tsxReverted to original (matches dev)HeroBar (canonical)
cliproxy.tsxReverted to original (matches dev)Rail-anchored identity (canonical)
health.tsxBespoke redesign on top of the original migrationBespoke (canonical for §1d)
+ +

2 · health redesign — before / after

+

The redesigned health page surfaces severity (HEALTH.ATTENTIONREQUIRED with priority cards) above the audit grid (HEALTH.ENVIRONMENTAUDIT). Status ribbon at top carries summary + version + ccs doctor + refresh. Dynamic background tints rose when issues exist, emerald when clean. New domain components: HealthStatusRibbon, HealthPriorityCard, HealthPriorityList, HealthAuditSection.

+
+
+
+
+ +

3 · Four identity-strip patterns (v1.2 docs)

+ + + + + + + +
PatternReferenceWhen
HeroBar (1-row dense)pages/home.tsxDashboard pages with ≤4 hero stats packed tight
Rail-anchored (no top chrome)pages/cliproxy.tsxMulti-entity Config pages where rail carries identity
PageHeader (title + desc + actions)None yetPages where description / status info is non-redundant context
Bespoke (full custom)pages/health.tsxSeverity / priority-driven pages with custom hierarchy
+ +

4 · What's actually in this PR

+ + + + + + + + + + + + + + + +
FileChange
ui/src/pages/health.tsxBespoke redesign — replaces the Monitor migration with priority + audit hierarchy
ui/src/components/health/health-status-ribbon.tsxNEW — top status strip (severity, KPIs, version, actions)
ui/src/components/health/health-priority-card.tsxNEW — clickable card surfacing a single failed/warning check
ui/src/components/health/health-priority-list.tsxNEW — grid of priority cards under HEALTH.ATTENTIONREQUIRED
ui/src/components/health/health-audit-section.tsxNEW — collapsible section for the lower audit grid
ui/src/components/layout/layout.tsxDrop backdrop-blur on global header (interferes with health backdrop)
ui/src/pages/home.tsxReverted to original (matches dev)
ui/src/pages/cliproxy.tsxReverted to original (matches dev)
ui/src/components/monitor-layout/kpi.tsxKpiCard gains optional onClick prop (still useful for future monitor pages)
ui/docs/design-system.mdv1.2 — four identity-strip patterns, Bespoke added as §1d
ui/docs/design-decisions.mdv1.2 revision row capturing the bespoke health redesign
ui/docs/health-redesign/Verification report and screenshots from the redesign exercise
+ +

+PR #1089 · plan plans/260425-1132-ccs-dashboard-design-system/ · health brief plans/reports/handoff-260425-1417-health-page-redesign.md +

+
\ No newline at end of file diff --git a/ui/docs/design-system.md b/ui/docs/design-system.md index edae68af..6512fdee 100644 --- a/ui/docs/design-system.md +++ b/ui/docs/design-system.md @@ -1,36 +1,116 @@ # 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`. +A page-level design system extracted from the **canonical reference pages** — `home` and `cliproxy` — that already prove the patterns work in production. New pages should adapt to these references, not the other way around. + +Some pages legitimately need a bespoke design (the redesigned `health` page is the current example) — when content shape demands custom hierarchy, the system should step out of the way rather than force the page into a wrong-fit archetype. > Live preview in dev: `bun run dev` then visit `/_styleguide`. --- -## 1. The shell — every page +## 1. Identity-strip patterns (pick one per page) + +Three patterns cover every page in the dashboard. The choice depends on what your page already has. + +### 1a. `HeroBar` — single-row dense hero + +**Canonical reference:** `pages/home.tsx` ``` -PageShell -├─ PageHeader (title · description · status · actions) -└─ +┌────────────────────────────────────────────────────────────────────┐ +│ [logo] Title [version] ┃ [Stat] [Stat] [Stat] [Stat] │ +└────────────────────────────────────────────────────────────────────┘ ``` -```tsx - - - {/* OR */} - +One row packs logo + title + version + ≤4 inline stats. Optional subtle dotted-pattern background. Stats are clickable when they double as navigation entry points. + +**Use it when:** +- The page is a dashboard / monitor with a clear product identity +- ≤4 hero stats summarize the page in numbers +- Vertical real estate matters (this is half the height of a stacked PageHeader + KpiRow) + +**Building blocks:** +- `` — logo + title + subtitle from `components/layout/hero-section.tsx` +- `` — clickable stat tile (extracted from `home.tsx`); promote to a shared primitive when a 2nd page adopts it + +### 1b. Rail-anchored identity — no top chrome + +**Canonical reference:** `pages/cliproxy.tsx` + +``` +┌──────────┬─────────────────────────────────────────────────────────┐ +│ ⚡ Brand │ │ +│ subtitle │ │ +│ [QSetup] │ full-height 3-pane body │ +│ │ │ +│ • prov A │ (form + raw json fill the entire viewport) │ +│ • prov B │ │ +│ … │ │ +│ [status] │ │ +└──────────┴─────────────────────────────────────────────────────────┘ ``` -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. +Page identity (brand + page-level CTA + status) lives **inside the left rail**. Zero top chrome — the body archetype gets the full vertical viewport. + +**Use it when:** +- The page is a multi-entity Config (3-pane: list / form / json) +- The rail naturally carries the page name (you'd duplicate it in a top header) +- Vertical real estate is at a premium because the body has dense form content + +**Building blocks:** +- The left rail's own header section (in-place markup, no extracted primitive yet — keep it bespoke until a 2nd page adopts the pattern) +- Recommended order in the rail: brand strip → primary CTA → entity list → status widget → footer summary + +### 1c. `PageHeader` — title-row chrome + +**Canonical reference:** none yet (was `health.tsx` until its bespoke redesign — see §1d). + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Title [v-badge] [action] [action] │ +│ Description / last-update / status info │ +└────────────────────────────────────────────────────────────────────┘ +``` + +Traditional title row with description and trailing actions. + +**Use it when:** +- The page does NOT fit either canonical hero +- The description carries genuinely non-redundant context (last refresh, page hierarchy, filter state, version) +- Body archetype below benefits from a clear identity strip + +**API:** `` — title + description on left, status badges + action buttons on right. + +### 1d. Bespoke — full custom design + +**Canonical reference:** `pages/health.tsx` + +When a page's content shape demands its own hierarchy (priority-driven sections, dynamic backgrounds tied to status, custom card primitives like `HealthStatusRibbon` / `HealthPriorityCard`), the design system gets out of the way. Bespoke pages still respect global concerns (privacy mode, theme, sidebar) but build their own layout from scratch. + +**Use it when:** +- None of the three patterns above fits without distorting the content +- The page's information hierarchy is genuinely unique (e.g. severity-driven priority surfaces with secondary audit lists) +- A bespoke implementation will be clearly better than forcing a fit + +**Cost:** higher LOC, no reuse, no consistency — only justified when content demands it. + +### Decision table + +| Page shape | Identity strip | +|------------|---------------| +| Dashboard / overview with ≤4 hero stats | **HeroBar** (home pattern) | +| 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 | +| Severity / priority-driven page with custom hierarchy | **Bespoke** (health pattern) | +| Wizard / login / dialog | None — bespoke shell | --- -## 2. Config archetype — 3-pane +## 2. Body archetypes -Used by every page that configures one or more entities (cliproxy, accounts, codex, copilot, cursor, droid, claude-extension, api, shared, profiles). +### 2a. Config — 3-pane + +**Canonical reference:** `pages/cliproxy.tsx` ``` ┌──────────┬──────────────────┬──────────┐ @@ -39,141 +119,74 @@ Used by every page that configures one or more entities (cliproxy, accounts, cod └──────────┴──────────────────┴──────────┘ ``` -One component, prop-controlled left rail: +Left rail = `ListPane` (multi-entity) or `SectionRail` (single-entity, with `IntersectionObserver` scroll-spy). Form and JSON panes are middle and right respectively. ```tsx -// Multi-entity (cliproxy, accounts, providers) } + left={} // multi-entity + // OR + left={} // single-entity 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 +**Rules:** +- Save action lives **only** in `FormPane` footer +- `<1024px`: collapses to tabs (Browse | Configure | JSON) +- `JsonPane` is read-only by default; opt-in `editable` for cliproxy-style inline editing -- **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). +### 2b. Monitor — KPI row + 12-col grid -### 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). +**Canonical reference:** none in this PR. Health used to be the reference but went bespoke (§1d). The primitives (`MonitorLayout`, `KpiRow`, `KpiCard`, `MonitorGrid`, `MonitorCard`) ship and remain available; first page to genuinely need them becomes the next reference. ``` -┌────────────────────────────────────────────────┐ -│ KpiRow: [KPI] [KPI] [KPI] [KPI] │ (optional, ≤4 hero numbers) -├────────────────────────────────────────────────┤ -│ MonitorGrid (12-col): │ -│ primary viz │ -│ side widget │ -│ ... │ -│ ... │ -└────────────────────────────────────────────────┘ +┌────────────────────────────────────────┐ +│ KpiRow (≤4 hero numbers) │ +├────────────────────────────────────────┤ +│ MonitorGrid (12-col): │ +│ │ +└────────────────────────────────────────┘ ``` ```tsx - - - … - - } -> +…}> - … - … - … - … + … ``` -### Rules +**Rules:** +- `KpiRow` only when ≤4 hero numbers; more → group inside the grid +- One primary viz per page, span ≥8 cols +- `variant="terminal"` for live-log / `health --watch` aesthetics -- **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. +--- + +## 3. Composing a new page + +```tsx +// Example: a new dashboard-style page + + {/* or PageHeader, or rail-anchored identity */} + {/* or ConfigLayout */} + +``` + +Target LOC for a new page: **~80** for typical config, **~120** for monitor with hero strip. Target LOC for an outlier rewrite: **<400**. --- ## 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. +These remain bespoke and are out of scope: +- `/login` — minimal centered shell +- Setup wizard — modal overlay +- Dialogs — Radix `Dialog` --- -## 5. Composing a new page +## 5. Decisions -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. +See [`design-decisions.md`](./design-decisions.md) for the resolved open questions and the v1.1 revision rationale. diff --git a/ui/docs/health-redesign/expanded_view.png b/ui/docs/health-redesign/expanded_view.png new file mode 100644 index 00000000..da449a53 Binary files /dev/null and b/ui/docs/health-redesign/expanded_view.png differ diff --git a/ui/docs/health-redesign/fixed_expansion.png b/ui/docs/health-redesign/fixed_expansion.png new file mode 100644 index 00000000..12963bef Binary files /dev/null and b/ui/docs/health-redesign/fixed_expansion.png differ diff --git a/ui/docs/health-redesign/initial_state.png b/ui/docs/health-redesign/initial_state.png new file mode 100644 index 00000000..c63038af Binary files /dev/null and b/ui/docs/health-redesign/initial_state.png differ diff --git a/ui/docs/health-redesign/loading_state.png b/ui/docs/health-redesign/loading_state.png new file mode 100644 index 00000000..2223d19b Binary files /dev/null and b/ui/docs/health-redesign/loading_state.png differ diff --git a/ui/docs/health-redesign/report.html b/ui/docs/health-redesign/report.html new file mode 100644 index 00000000..80cad7dc --- /dev/null +++ b/ui/docs/health-redesign/report.html @@ -0,0 +1,129 @@ + + + + + + UI Verification Report - Health Page Redesign + + + + + + + +
+ +
+
+

UI Verification Report

+

Project: CCS CLI Health Dashboard Redesign

+
+
+ Pass +

Generated: April 25, 2026

+
+
+ + +
+

+ + Implementation Overview +

+
+
+

Aesthetic Improvements

+
    +
  • ✓ Solid opaque header with shadow separation
  • +
  • ✓ Contained mesh gradient (no top-bar bleed)
  • +
  • ✓ Premium grainy texture via local SVG asset
  • +
  • ✓ Refined typography and spacing (OKLCH colors)
  • +
+
+
+

Functional Verification

+
    +
  • ✓ Priority cards collapsed by default (Fix for 'blank space' bug)
  • +
  • ✓ Entire card header now clickable for expansion
  • +
  • ✓ Refresh button loading state confirmed
  • +
  • ✓ "Apply Fix" spinner feedback
  • +
+
+
+
+ + +
+

Redteam Review & Edge Cases

+
+
+

Edge Case: Asset Availability

+

Problem: External SVG noise textures were failing (404).

+

Resolution: Switched to a robust Base64 Data URI for the noise texture in health.tsx. This ensures the premium texture renders even in offline or high-security environments.

+
+
+

Edge Case: Responsive Overlap

+

Problem: Mesh gradient blur spilling into the header and sidebar.

+

Resolution: Implemented overflow-hidden on the content wrapper and adjusted mesh circles to top-[5%] instead of negative offsets. Added z-20 and solid background to the header for absolute containment.

+
+
+
+ + +
+

Visual Proof

+ +
+
+

1. Initial State & Header Separation

+
+ Initial State +
+

Note the clean separation between the white header and the mesh background.

+
+ +
+

2. Interactive Loading Feedback

+
+ Loading State +
+

Spinner state triggered during 'Refresh' action.

+
+ +
+

3. Expanded Issue Drill-down

+
+ Expanded Issue +
+

Detailed issue view with clear hierarchy and typography.

+
+ +
+

4. Expansion Fix (Header Clickable)

+
+ Expansion Fix +
+

Corrected expansion logic: Cards start collapsed, and expanding them shows the fix without 'blank space'.

+
+
+
+ + +
+
+ + READY FOR PRODUCTION INTEGRATION +
+

Verified by Antigravity AI Assistant

+
+
+ + diff --git a/ui/docs/screenshots-phase2/after-03-health.png b/ui/docs/screenshots-phase2/after-03-health.png new file mode 100644 index 00000000..f5f0728e Binary files /dev/null and b/ui/docs/screenshots-phase2/after-03-health.png differ diff --git a/ui/docs/screenshots-phase2/before-03-health.png b/ui/docs/screenshots-phase2/before-03-health.png new file mode 100644 index 00000000..4e36b4f7 Binary files /dev/null and b/ui/docs/screenshots-phase2/before-03-health.png differ diff --git a/ui/src/components/health/health-audit-section.tsx b/ui/src/components/health/health-audit-section.tsx new file mode 100644 index 00000000..db4dfc8f --- /dev/null +++ b/ui/src/components/health/health-audit-section.tsx @@ -0,0 +1,93 @@ +import { type HealthGroup } from '@/hooks/use-health'; +import { HealthCheckItem } from './health-check-item'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ChevronRight, ShieldCheck, Info } from 'lucide-react'; +import { useState } from 'react'; +import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; + +interface HealthAuditSectionProps { + groups: HealthGroup[]; +} + +export function HealthAuditSection({ groups }: HealthAuditSectionProps) { + const { t } = useTranslation(); + + return ( +
+
+

+ {t('health.environmentAudit')} +

+
+ +
+ +
+ {groups.map((group) => ( + + ))} +
+
+ ); +} + +function AuditGroup({ group }: { group: HealthGroup }) { + const [isOpen, setIsOpen] = useState(false); + const issuesCount = group.checks.filter( + (c) => c.status === 'error' || c.status === 'warning' + ).length; + const hasIssues = issuesCount > 0; + + return ( + +
+ + + + + +
+ {group.checks.map((check) => ( + + ))} +
+
+
+
+ ); +} diff --git a/ui/src/components/health/health-priority-card.tsx b/ui/src/components/health/health-priority-card.tsx new file mode 100644 index 00000000..b74df697 --- /dev/null +++ b/ui/src/components/health/health-priority-card.tsx @@ -0,0 +1,179 @@ +import { + AlertCircle, + AlertTriangle, + Copy, + Terminal, + Wrench, + ChevronRight, + RefreshCw, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useFixHealth, type HealthCheck } from '@/hooks/use-health'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; + +interface HealthPriorityCardProps { + check: HealthCheck; +} + +export function HealthPriorityCard({ check }: HealthPriorityCardProps) { + const { t } = useTranslation(); + const fixMutation = useFixHealth(); + const hasContent = !!(check.details || check.fix || check.fixable); + const [isExpanded, setIsExpanded] = useState(false); + + const isError = check.status === 'error'; + const Icon = isError ? AlertCircle : AlertTriangle; + + const copyFix = () => { + if (check.fix) { + navigator.clipboard.writeText(check.fix); + toast.success(t('health.copied')); + } + }; + + const headerInner = ( + <> + {/* Status Icon with Ring */} +
+ +
+ +
+
+

{check.name}

+ {hasContent && ( +
+ +
+ )} +
+

+ {check.message} +

+
+ + ); + + return ( + +
+ {/* Double-Bezel Inner Highlight */} +
+ +
+ {/* + * Header acts as the expand/collapse trigger when there's + * additional content (details, fix, fixable). Render as + ) : ( +
{headerInner}
+ )} + + + {isExpanded && ( + +
+ {check.details && ( +
+ {check.details} +
+ )} + + {(check.fix || check.fixable) && ( +
+ {check.fix && ( +
+ + {check.fix} + {/* + * Always-visible copy button. The previous design hid it behind + * group-hover, which excluded keyboard users and touch-only + * devices (no hover state on mobile = effectively unreachable). + */} + +
+ )} + + {check.fixable && ( + + )} +
+ )} +
+
+ )} +
+
+
+ + ); +} diff --git a/ui/src/components/health/health-priority-list.tsx b/ui/src/components/health/health-priority-list.tsx new file mode 100644 index 00000000..84836918 --- /dev/null +++ b/ui/src/components/health/health-priority-list.tsx @@ -0,0 +1,34 @@ +import { HealthPriorityCard } from './health-priority-card'; +import { type HealthCheck } from '@/hooks/use-health'; +import { AnimatePresence } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; + +interface HealthPriorityListProps { + checks: HealthCheck[]; +} + +export function HealthPriorityList({ checks }: HealthPriorityListProps) { + const { t } = useTranslation(); + + if (checks.length === 0) return null; + + return ( +
+
+

+ {t('health.attentionRequired')} +

+
+ {checks.length} +
+ +
+ + {checks.map((check) => ( + + ))} + +
+
+ ); +} diff --git a/ui/src/components/health/health-status-ribbon.tsx b/ui/src/components/health/health-status-ribbon.tsx new file mode 100644 index 00000000..5e6c9867 --- /dev/null +++ b/ui/src/components/health/health-status-ribbon.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from 'react'; +import { Cpu, RefreshCw, Terminal, Copy } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +interface HealthStatusRibbonProps { + summary: { + passed: number; + warnings: number; + errors: number; + total: number; + info: number; + }; + version: string; + lastScan: number; + isLoading: boolean; + onRefresh: () => void; +} + +function formatRelativeTime( + timestamp: number, + t: (key: string, options?: Record) => string +) { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 5) return t('health.justNow'); + if (seconds < 60) return t('health.secondsAgo', { count: seconds }); + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return t('health.minutesAgo', { count: minutes }); + // Roll up to hours so a stale "120 minutes ago" reads as "2 hours ago" + const hours = Math.floor(minutes / 60); + return t('health.hoursAgo', { count: hours }); +} + +export function HealthStatusRibbon({ + summary, + version, + lastScan, + isLoading, + onRefresh, +}: HealthStatusRibbonProps) { + const { t } = useTranslation(); + + // formatRelativeTime reads Date.now() during render. Without a ticking + // re-render, the "last scan" label freezes at whatever it showed on mount + // and never advances to "1 minute ago", "2 hours ago", etc. — even though + // the underlying lastScan timestamp is stale. Force a re-render every + // second; the cost is negligible (one tiny component) and the UX win is + // a label that actually behaves like a relative timestamp. + const [, setTick] = useState(0); + useEffect(() => { + const interval = setInterval(() => setTick((n) => n + 1), 1000); + return () => clearInterval(interval); + }, []); + + const copyDoctorCommand = () => { + navigator.clipboard.writeText('ccs doctor'); + toast.success(t('health.copied')); + }; + + const hasIssues = summary.errors > 0 || summary.warnings > 0; + + return ( +
+ {/* Animated Mesh Gradient Background */} +
+ +
+ {/* Status Indicator */} +
+
+
+
+
+

+ {hasIssues ? t('health.issuesDetected') : t('health.systemOptimal')} +

+
+ +
+ + {/* Stats Summary */} +
+
+ + Checks + + {summary.total} +
+
+ + Passed + + {summary.passed} +
+ {summary.warnings > 0 && ( +
+ + Warnings + + {summary.warnings} +
+ )} + {summary.errors > 0 && ( +
+ + Errors + + + {summary.errors} + +
+ )} +
+ +
+ + {/* Meta & Actions */} +
+
+
+ + {lastScan ? formatRelativeTime(lastScan, t) : '--'} +
+
+ + {version} +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/health/index.ts b/ui/src/components/health/index.ts index 16eac15e..205877de 100644 --- a/ui/src/components/health/index.ts +++ b/ui/src/components/health/index.ts @@ -7,3 +7,6 @@ export { HealthCheckItem } from './health-check-item'; export { HealthGauge } from './health-gauge'; export { HealthGroupSection } from './health-group-section'; export { HealthStatsBar } from './health-stats-bar'; +export { HealthStatusRibbon } from './health-status-ribbon'; +export { HealthPriorityList } from './health-priority-list'; +export { HealthAuditSection } from './health-audit-section'; diff --git a/ui/src/components/layout/layout.tsx b/ui/src/components/layout/layout.tsx index 0e69960d..9216a2c4 100644 --- a/ui/src/components/layout/layout.tsx +++ b/ui/src/components/layout/layout.tsx @@ -41,7 +41,7 @@ export function Layout() {
-
+
diff --git a/ui/src/components/monitor-layout/kpi.tsx b/ui/src/components/monitor-layout/kpi.tsx index f7b96012..0b3b5f9e 100644 --- a/ui/src/components/monitor-layout/kpi.tsx +++ b/ui/src/components/monitor-layout/kpi.tsx @@ -27,6 +27,14 @@ interface KpiCardProps { /** Tone for the hint line (e.g. positive delta, warning, etc.). */ tone?: 'default' | 'positive' | 'warning' | 'negative'; icon?: ReactNode; + /** + * Optional click handler. When set, the tile renders as a button with + * keyboard + hover affordances — used by pages where each KPI is also a + * navigation entry point (e.g. home dashboard). + */ + onClick?: () => void; + /** Accessible label override when onClick is set. Defaults to `label`. */ + ariaLabel?: string; className?: string; } @@ -39,10 +47,22 @@ const TONE_CLASSES: Record, string> = { /** * KpiCard - Single hero stat tile inside a KpiRow. + * + * Renders as a static
by default; promotes to a clickable + ); + } + + return
{content}
; } diff --git a/ui/src/pages/_styleguide.tsx b/ui/src/pages/_styleguide.tsx index 349f3505..730aba86 100644 --- a/ui/src/pages/_styleguide.tsx +++ b/ui/src/pages/_styleguide.tsx @@ -8,11 +8,26 @@ * safe to publish in PRs without enabling Privacy mode. */ import { useState } from 'react'; -import { Activity, Bot, Cloud, Cpu, Plus, RefreshCcw, ShieldCheck, Zap } from 'lucide-react'; +import { + Activity, + Bot, + Cloud, + Cpu, + Key, + Plus, + RefreshCcw, + RefreshCw, + ShieldCheck, + Sparkles, + Users, + Zap, +} 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'; import { ConfigLayout, @@ -60,7 +75,25 @@ export function StyleguidePage() { return (
- + + + + + + + + + + @@ -121,9 +154,15 @@ function Intro() {

CCS Dashboard Design System

- Two archetypes — Config (3-pane: list | form | JSON) and{' '} - Monitor (KPI row + grid) — wrapped by PageShell. Every page - picks one archetype. + Three identity-strip patterns — HeroBar (one-row dense, see /{' '} + home page), rail-anchored (no top chrome, see /cliproxy), and{' '} + PageHeader (title + description + actions, see /health) — and + two body archetypes: Config (3-pane: list / form / JSON) and{' '} + Monitor (KPI row + grid). Pick the identity strip that matches your + page's shape; pick the body archetype your content needs. +

+

+ See ui/docs/design-system.md for the decision table.

); @@ -147,7 +186,195 @@ function PrimitiveSection({ } // ----------------------------------------------------------------------------- -// PageHeader demo +// HeroBar demo (canonical: pages/home.tsx) +// +// One row packs logo + title + version + ≤4 inline stats. Optional dotted +// background pattern. Stats are clickable when they double as nav entry points. +// Use it for dashboard pages with a clear product identity and ≤4 hero numbers. +// ----------------------------------------------------------------------------- + +function DemoInlineStat({ + title, + value, + icon: Icon, + variant = 'default', +}: { + title: string; + value: string | number; + icon: typeof Activity; + variant?: 'default' | 'accent' | 'success' | 'warning'; +}) { + const styles = { + default: 'bg-muted text-muted-foreground', + accent: 'bg-accent/15 text-accent', + success: 'bg-emerald-600/15 text-emerald-700 dark:text-emerald-400', + warning: 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + }[variant]; + const valueColor = { + default: 'text-foreground', + accent: 'text-accent', + success: 'text-emerald-700 dark:text-emerald-400', + warning: 'text-amber-700 dark:text-amber-400', + }[variant]; + + return ( + + ); +} + +function DemoHeroBar() { + return ( +
+
+
+
+
+
+
+
+ +
+
+
+

Demo Dashboard

+ + v1.0.0 + +
+

+ Single-row dense hero — logo + title + version + inline stats +

+
+
+
+ + + + +
+
+
+

+ Body content renders below — no separate KpiRow needed since stats are absorbed into the + hero strip. +

+
+ ); +} + +// ----------------------------------------------------------------------------- +// Rail-anchored identity demo (canonical: pages/cliproxy.tsx) +// +// Page identity (brand + page-level CTA + status) lives inside the left rail of +// a Config 3-pane layout. Zero top chrome. The body archetype gets the full +// vertical viewport. Use for multi-entity Config pages where the rail naturally +// carries the page name. +// ----------------------------------------------------------------------------- + +function DemoRailAnchored() { + const [selectedId, setSelectedId] = useState('provider-a'); + + const railHeader = ( +
+ {/* Brand strip — replaces the global PageHeader */} +
+
+
+ +

Demo Brand

+
+ +
+

Page subtitle / description

+ +
+ + {/* Provider list */} + +
+
+ Providers +
+
+ {DEMO_PROVIDERS.map((p) => ( + + ))} +
+
+
+ + {/* Footer status */} +
+
+ 4 providers + + 1 connected + +
+
+
+ ); + + return ( +
+ {/* NOTE: NO PageHeader at the top. Identity is inside the rail. */} + Provider A

} + footer={} + > + + + + + } + json={} + /> +
+ ); +} + +// ----------------------------------------------------------------------------- +// PageHeader demo (canonical: pages/health.tsx) +// +// 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). // ----------------------------------------------------------------------------- function DemoPageHeader() { diff --git a/ui/src/pages/health.tsx b/ui/src/pages/health.tsx index 5239fd5c..4a1dbf3f 100644 --- a/ui/src/pages/health.tsx +++ b/ui/src/pages/health.tsx @@ -1,78 +1,34 @@ -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; -import { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react'; -import { HealthGauge } from '@/components/health/health-gauge'; -import { HealthStatsBar } from '@/components/health/health-stats-bar'; -import { HealthGroupSection } from '@/components/health/health-group-section'; -import { useHealth, type HealthGroup } from '@/hooks/use-health'; +import { ShieldCheck } from 'lucide-react'; +import { HealthStatusRibbon, HealthPriorityList, HealthAuditSection } from '@/components/health'; +import { useHealth } from '@/hooks/use-health'; import { cn } from '@/lib/utils'; -import { toast } from 'sonner'; -import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; import { useTranslation } from 'react-i18next'; -function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) { - if (summary.errors > 0) return 'error'; - if (summary.warnings > 0) return 'warning'; - return 'ok'; -} - -function formatRelativeTime( - timestamp: number, - t: (key: string, options?: Record) => string -): string { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 5) return t('health.justNow'); - if (seconds < 60) return t('health.secondsAgo', { count: seconds }); - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return t('health.minutesAgo', { count: minutes }); - const hours = Math.floor(minutes / 60); - return t('health.hoursAgo', { count: hours }); -} - -function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] { - return [...groups].sort((a, b) => { - const aErrors = a.checks.filter((c) => c.status === 'error').length; - const bErrors = b.checks.filter((c) => c.status === 'error').length; - const aWarnings = a.checks.filter((c) => c.status === 'warning').length; - const bWarnings = b.checks.filter((c) => c.status === 'warning').length; - if (aErrors !== bErrors) return bErrors - aErrors; - return bWarnings - aWarnings; - }); -} - -function TerminalHeader() { - return ( -
- $ - ccs doctor -
- ); -} - function LoadingSkeleton() { return ( -
- {/* Hero skeleton */} -
-
- -
- - - +
+ {/* Ribbon skeleton */} + + +
+ {/* Priority skeleton */} +
+ +
+ +
-
- {/* Stats skeleton */} - - - {/* Groups skeleton */} -
- {[1, 2, 3, 4].map((i) => ( - - ))} + {/* Audit skeleton */} +
+ + {[1, 2, 3].map((i) => ( + + ))} +
); @@ -82,180 +38,98 @@ export function HealthPage() { const { t } = useTranslation(); const { data, isLoading, refetch, dataUpdatedAt } = useHealth(); - // Use dataUpdatedAt directly instead of storing in state - const lastRefresh = dataUpdatedAt; - - // Update relative time display by forcing re-render every second - const [tick, setTick] = useState(0); - useEffect(() => { - const interval = setInterval(() => setTick((t) => t + 1), 1000); - return () => clearInterval(interval); - }, []); - // Consume tick to prevent unused variable warning - void tick; - - const copyDoctorCommand = () => { - navigator.clipboard.writeText('ccs doctor'); - toast.success(t('health.copied')); - }; - - const handleRefresh = () => { - refetch(); - toast.info(t('health.refreshing')); - }; - if (isLoading && !data) { return ; } - const overallStatus = data ? getOverallStatus(data.summary) : 'ok'; - const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : []; + const priorityChecks = + data?.checks.filter((c) => c.status === 'error' || c.status === 'warning') ?? []; + const hasIssues = priorityChecks.length > 0; return ( -
- {/* Hero Section - Terminal-inspired control center header */} -
- {/* Subtle scan lines effect */} +
+ {/* Dynamic Background */} +
+
+ {/* Grain overlay - Local SVG data URI for reliability */} +
- - {/* Grid pattern background */} -
-
-
- -
- {/* Left: Health Gauge - excludes info from percentage */} - {data && ( -
- -
- )} - - {/* Center: Title and status */} -
- {/* Terminal prompt */} - - - {/* Main title */} -
-

- {t('health.systemHealth')} -

- {data?.version && ( - - {t('health.build', { version: data.version })} - - )} -
- - {/* Status message */} -
- - {t('health.lastScan')} - - {lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'} - - | - {t('health.autoRefresh')} - 30s -
-
- - {/* Right: Actions */} -
- - -
-
- {/* Stats Bar */} - {data && ( -
- + {/* Status Ribbon */} + {data && ( + -
- )} + )} - {/* Health Check Groups - Single column layout */} - {sortedGroups.length > 0 && ( -
- {sortedGroups.map((group, index) => ( - c.status === 'error' || c.status === 'warning') - } - /> - ))} -
- )} +
+ {/* Priority Issues */} + {hasIssues ? ( + + ) : ( + +
+
+ +
+
+
+
+

{t('health.allSystemsClear')}

+

+ {t('health.optimalStateDesc')} +

+
+ + )} - {/* Footer metadata */} -
-
- - {t('health.version')} {data?.version ?? '--'} - - - {t('health.platform')}{' '} - - {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} - - + {/* Detailed Audit */} + {data?.groups && }
-
-
- {t('health.liveMonitoring')} + + {/* Footer metadata */} +
+
+
+ Build + {data?.version ?? '--'} +
+
+ Platform + + {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} + +
+
+
+
+ {t('health.liveMonitoring')} +