diff --git a/docs/superpowers/plans/2026-07-02-chicorytv-spa-component-primitives.md b/docs/superpowers/plans/2026-07-02-chicorytv-spa-component-primitives.md new file mode 100644 index 000000000..3a34a6a82 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-chicorytv-spa-component-primitives.md @@ -0,0 +1,135 @@ +# ChicoryTV SPA Component Primitives Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement issue #80 by recreating the design-system primitive components as typed React components in the SPA and adding a gallery view for visual review across all three themes. + +**Architecture:** Components live under `web/src/components/` and are grouped by domain with a barrel export. Visual styling lives in one shared CSS file using existing ChicoryTV design tokens imported by `web/src/designSystem.ts`; gallery composition lives in `App.tsx` and exercises every primitive. + +**Tech Stack:** React 19, TypeScript, Vite, Vitest, Testing Library, lucide-react, existing `design-system/styles.css` tokens. + +--- + +### Task 1: Baseline and Failing Component Tests + +**Files:** +- Modify: `web/src/App.test.tsx` + +- [ ] **Step 1: Run baseline tests** + +Run: `npm test -- --run` from `web/`. + +Expected: existing SPA scaffold tests pass before new work. + +- [ ] **Step 2: Write failing tests** + +Add tests that import the upcoming component barrel, render the gallery, and verify key API behavior: + +```tsx +import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Tooltip } from './components'; + +it('exports typed primitives with expected interactions', () => { + const onSwitch = vi.fn(); + const onCheckbox = vi.fn(); + const onTab = vi.fn(); + + render( + <> + Saving + + + {}} /> + + + Reset + > + ); + + expect(screen.getByRole('button', { name: 'Saving' })).toBeDisabled(); + fireEvent.click(screen.getByRole('switch', { name: 'Show disabled' })); + expect(onSwitch).toHaveBeenCalledWith(true); + expect(screen.getByRole('checkbox', { name: 'Select all' })).toHaveAttribute('aria-checked', 'mixed'); + expect(screen.getByText('Already in use')).toBeInTheDocument(); + expect(screen.getByText('62%')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'Streaming' })); + expect(onTab).toHaveBeenCalledWith('streaming'); + fireEvent.mouseEnter(screen.getByText('Reset')); + expect(screen.getByRole('tooltip')).toHaveTextContent('Reset playout'); +}); +``` + +- [ ] **Step 3: Verify red** + +Run: `npm test -- --run App.test.tsx` from `web/`. + +Expected: failure because `./components` does not exist yet. + +### Task 2: Implement Component Library + +**Files:** +- Create: `web/src/components/index.ts` +- Create: `web/src/components/components.css` +- Create: `web/src/components/forms.tsx` +- Create: `web/src/components/dataDisplay.tsx` +- Create: `web/src/components/feedback.tsx` +- Create: `web/src/components/navigation.tsx` + +- [ ] **Step 1: Implement typed exports** + +Implement these exports with props matching `design-system/components/**/*.d.ts`: `Button`, `IconButton`, `Input`, `Select`, `Switch`, `Checkbox`, `Badge`, `StatusDot`, `Tag`, `ChannelLogo`, `Card`, `Stat`, `ProgressBar`, `Toast`, `Tooltip`, `Spinner`, `NavItem`, `NavSection`, `Tabs`. + +- [ ] **Step 2: Implement token-driven CSS** + +Use `components.css` classes for variants, sizes, focus, hover, disabled, active, live pulse, indeterminate progress, and theme-aware colors through `--ctv-*` and semantic aliases. + +- [ ] **Step 3: Verify green** + +Run: `npm test -- --run App.test.tsx` from `web/`. + +Expected: new component behavior tests pass. + +### Task 3: Add Component Gallery + +**Files:** +- Modify: `web/src/App.tsx` +- Modify: `web/src/shell.css` +- Modify: `web/src/App.test.tsx` + +- [ ] **Step 1: Add failing gallery assertions** + +Assert the SPA renders "Component gallery", "Forms", "Data display", "Feedback", and "Navigation", and includes one visible sample of each primitive family. + +- [ ] **Step 2: Verify red** + +Run: `npm test -- --run App.test.tsx` from `web/`. + +Expected: failure because the gallery is not implemented. + +- [ ] **Step 3: Implement gallery** + +Compose the new primitives in `App.tsx`, matching the specimen cards' sample content and retaining the three-theme switcher. + +- [ ] **Step 4: Verify green** + +Run: `npm test -- --run App.test.tsx` from `web/`. + +Expected: App tests pass. + +### Task 4: Final Verification and Issue Completion + +**Files:** +- Inspect all changed files with `git diff`. + +- [ ] **Step 1: Run verification** + +Run from `web/`: + +```bash +npm test -- --run +npm run typecheck +npm run build +``` + +- [ ] **Step 2: Complete done workflow** + +Use the `done` skill for issue #80: read the issue, verify acceptance criteria, check docs impact, run `git status`, commit and push if files changed, comment on Gitea, and close the issue if complete. diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 90c56329f..eb95c785f 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,6 +1,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from './App'; +import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Tooltip } from './components'; import { applyDesignSystemTheme, designSystemStylesheet, @@ -73,4 +74,61 @@ describe('ChicoryTV SPA scaffold', () => { 'true' ); }); + + it('renders the component gallery primitives', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Component gallery' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Forms' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Data display' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Feedback' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Navigation' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add Channel' })).toBeInTheDocument(); + expect(screen.getByText('HLS Segmenter')).toBeInTheDocument(); + expect(screen.getByText('Channel saved')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Streaming' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('exports typed primitives with expected interactions', () => { + const onSwitch = vi.fn(); + const onCheckbox = vi.fn(); + const onTab = vi.fn(); + + render( + <> + + Saving + + + + {}} /> + + + + Reset + + > + ); + + expect(screen.getByRole('button', { name: 'Saving' })).toBeDisabled(); + fireEvent.click(screen.getByRole('switch', { name: 'Show disabled' })); + expect(onSwitch).toHaveBeenCalledWith(true); + expect(screen.getByRole('checkbox', { name: 'Select all' })).toHaveAttribute( + 'aria-checked', + 'mixed' + ); + expect(screen.getByText('Already in use')).toBeInTheDocument(); + expect(screen.getByText('62%')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'Streaming' })); + expect(onTab).toHaveBeenCalledWith('streaming'); + fireEvent.mouseEnter(screen.getByText('Reset')); + expect(screen.getByRole('tooltip')).toHaveTextContent('Reset playout'); + }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 710703e96..ac7faa48e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,44 @@ import { useEffect, useState } from 'react'; import { + Activity, CalendarClock, + FolderTree, + Info, LayoutDashboard, Library, ListVideo, Play, + Plus, Radio, + RefreshCw, + Search, Settings, + Tag as TagIcon, + Trash2, Tv } from 'lucide-react'; import chicoryIconUrl from '../../design-system/assets/chicorytv-icon.svg'; +import { + Badge, + Button, + Card, + ChannelLogo, + Checkbox, + IconButton, + Input, + NavItem, + NavSection, + ProgressBar, + Select, + Spinner, + Stat, + StatusDot, + Switch, + Tabs, + Tag, + Toast, + Tooltip +} from './components'; import { applyDesignSystemTheme, designSystemThemes, @@ -18,12 +47,12 @@ import { } from './designSystem'; const navItems = [ - { label: 'Dashboard', icon: LayoutDashboard, active: true }, - { label: 'Channels', icon: Tv }, - { label: 'Guide', icon: ListVideo }, - { label: 'Schedules', icon: CalendarClock }, - { label: 'Libraries', icon: Library }, - { label: 'Settings', icon: Settings } + { label: 'Dashboard', icon: , active: true }, + { label: 'Channels', icon: }, + { label: 'Guide', icon: }, + { label: 'Schedules', icon: }, + { label: 'Libraries', icon: }, + { label: 'Settings', icon: } ]; const stats = [ @@ -34,6 +63,9 @@ const stats = [ export function App() { const [theme, setTheme] = useState(() => getStoredDesignSystemTheme()); + const [showDisabled, setShowDisabled] = useState(true); + const [inEpg, setInEpg] = useState(true); + const [galleryTab, setGalleryTab] = useState('streaming'); useEffect(() => { applyDesignSystemTheme(theme); @@ -51,11 +83,8 @@ export function App() { - {navItems.map(({ label, icon: Icon, active }) => ( - - - {label} - + {navItems.map(({ label, icon, active }) => ( + ))} @@ -66,10 +95,7 @@ export function App() { Foundation SPA foundation ready - - - Open App - + }>Open App @@ -110,6 +136,167 @@ export function App() { ))} + + + + Issue #80 + Component gallery + + + + Forms} subtitle="Button, IconButton, Input, Select, Switch, Checkbox"> + + + }> + Add Channel + + Edit Numbers + + Cancel + + }> + Delete + + Saving + + + + + + + + + + + + + + + + {}} /> + {}} + options={['HLS Direct', 'MPEG-TS']} + /> + } placeholder="Search channels" /> + {}} + /> + + + + + Data display} subtitle="Badge, StatusDot, Tag, ChannelLogo, Stat, ProgressBar"> + + + + Live + + Healthy + Not normalizing + + Error + + HLS Segmenter + + + + + }>genre: comedy + {}}> + released 1990 + + + + + + + } delta="+2" deltaTone="up" /> + } /> + } /> + } /> + + + + + + + + + + Feedback} subtitle="Toast, Tooltip, Spinner"> + + + {}} + /> + {}} + /> + {}} + /> + + + + + + + + + + + + + + + + + + + Navigation} subtitle="NavItem, NavSection, Tabs"> + + + } label="Dashboard" /> + } label="Channels" active /> + } label="Schedules" /> + } + label="Playouts" + badge={3} + badgeTone="warn" + /> + Media + } label="Libraries" /> + } label="Collections" /> + + + + + + ); diff --git a/web/src/components/components.css b/web/src/components/components.css new file mode 100644 index 000000000..f8899746d --- /dev/null +++ b/web/src/components/components.css @@ -0,0 +1,958 @@ +.ctv-button, +.ctv-icon-button, +.ctv-switch-control, +.ctv-checkbox-control, +.ctv-tag-remove, +.ctv-toast-close, +.ctv-nav-item, +.ctv-tab { + font: inherit; +} + +.ctv-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: var(--control-h); + padding: 0 14px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + font-weight: var(--weight-medium); + white-space: nowrap; + transition: + background var(--dur-fast) var(--ease-standard), + border-color var(--dur-fast) var(--ease-standard), + color var(--dur-fast) var(--ease-standard), + transform var(--dur-fast) var(--ease-standard); +} + +.ctv-button:active:not(:disabled) { + transform: translateY(0.5px); +} + +.ctv-button:focus-visible, +.ctv-icon-button:focus-visible, +.ctv-switch-control:focus-visible, +.ctv-checkbox-control:focus-visible, +.ctv-tag-remove:focus-visible, +.ctv-toast-close:focus-visible, +.ctv-nav-item:focus-visible, +.ctv-tab:focus-visible { + outline: 0; + box-shadow: var(--ring-focus, 0 0 0 3px var(--focus-ring)); +} + +.ctv-button:disabled, +.ctv-icon-button:disabled, +.ctv-switch-control[aria-disabled="true"], +.ctv-checkbox-control[aria-disabled="true"], +.ctv-nav-item:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.ctv-button-sm { + height: var(--control-h-sm); + padding: 0 12px; + font-size: var(--text-xs); +} + +.ctv-button-full { + width: 100%; +} + +.ctv-button-primary { + background: var(--action-primary); + color: var(--text-on-accent); +} + +.ctv-button-primary:hover:not(:disabled) { + background: var(--action-primary-hover); +} + +.ctv-button-secondary { + border-color: var(--border-control); + background: var(--ctv-surface-2); + color: var(--text-primary); +} + +.ctv-button-secondary:hover:not(:disabled) { + background: var(--ctv-surface-3); +} + +.ctv-button-ghost { + background: transparent; + color: var(--text-secondary); +} + +.ctv-button-ghost:hover:not(:disabled) { + background: var(--ctv-surface-2); + color: var(--text-primary); +} + +.ctv-button-danger { + border-color: rgba(229, 72, 77, 0.35); + background: var(--ctv-error-soft); + color: var(--status-error); +} + +.ctv-button-danger:hover:not(:disabled) { + background: rgba(229, 72, 77, 0.22); +} + +.ctv-spinner { + display: inline-flex; + color: var(--action-primary); +} + +.ctv-spinner svg { + animation: ctv-spin 0.7s linear infinite; +} + +.ctv-spinner-muted { + color: var(--text-disabled); +} + +.ctv-spinner-ok { + color: var(--status-ok); +} + +.ctv-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 32px; + height: 32px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: + background var(--dur-fast) var(--ease-standard), + color var(--dur-fast) var(--ease-standard); +} + +.ctv-icon-button-sm { + width: 28px; + height: 28px; +} + +.ctv-icon-button:hover:not(:disabled), +.ctv-icon-button-active { + background: var(--ctv-surface-2); + color: var(--text-primary); +} + +.ctv-icon-button-solid { + background: var(--action-primary); + color: var(--text-on-accent); +} + +.ctv-icon-button-solid:hover:not(:disabled) { + background: var(--action-primary-hover); +} + +.ctv-field { + display: block; + width: auto; +} + +.ctv-field-full { + width: 100%; +} + +.ctv-field-label { + display: block; + margin-bottom: 6px; + color: var(--text-secondary); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + line-height: 1; +} + +.ctv-field-frame { + display: flex; + align-items: center; + gap: 8px; + height: var(--control-h); + border: 1px solid var(--border-control); + border-radius: var(--radius-sm); + background: var(--ctv-bg-sunken); + padding: 0 10px; + transition: + border-color var(--dur-fast) var(--ease-standard), + box-shadow var(--dur-fast) var(--ease-standard); +} + +.ctv-field-frame:focus-within { + border-color: var(--action-primary); + box-shadow: var(--ring-focus, 0 0 0 3px var(--focus-ring)); +} + +.ctv-field-frame-sm { + height: var(--control-h-sm); +} + +.ctv-field-frame-error { + border-color: var(--status-error); +} + +.ctv-field-frame-disabled { + opacity: 0.5; +} + +.ctv-field-icon, +.ctv-field-trailing { + display: inline-flex; + flex: 0 0 auto; + color: var(--text-disabled); +} + +.ctv-input, +.ctv-select { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--text-primary); + font: inherit; +} + +.ctv-input::placeholder { + color: var(--text-disabled); +} + +.ctv-field-error { + display: block; + margin-top: 5px; + color: var(--status-error); + font-size: var(--text-xs); + line-height: 1.3; +} + +.ctv-select-frame { + position: relative; + padding: 0; +} + +.ctv-select { + appearance: none; + cursor: pointer; + padding: 0 30px 0 10px; +} + +.ctv-select:disabled { + cursor: not-allowed; +} + +.ctv-select-chevron { + position: absolute; + right: 9px; + pointer-events: none; + color: var(--text-disabled); +} + +.ctv-switch, +.ctv-checkbox { + display: inline-flex; + align-items: center; + gap: 9px; + color: var(--text-primary); +} + +.ctv-switch-disabled, +.ctv-checkbox-disabled { + opacity: 0.5; +} + +.ctv-switch-control, +.ctv-checkbox-control { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + border: 1px solid var(--border-control); + cursor: pointer; + padding: 0; +} + +.ctv-switch-control { + position: relative; + width: 36px; + height: 20px; + border-radius: var(--radius-pill); + background: var(--ctv-surface-3); + transition: + background var(--dur-base) var(--ease-standard), + border-color var(--dur-base) var(--ease-standard); +} + +.ctv-switch-control[aria-checked="true"] { + border-color: transparent; + background: var(--action-primary); +} + +.ctv-switch-sm .ctv-switch-control { + width: 30px; + height: 17px; +} + +.ctv-switch-knob { + position: absolute; + left: 2px; + width: 15px; + height: 15px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); + transition: left var(--dur-base) var(--ease-out); +} + +.ctv-switch-control[aria-checked="true"] .ctv-switch-knob { + left: calc(100% - 17px); +} + +.ctv-switch-sm .ctv-switch-knob { + width: 12px; + height: 12px; +} + +.ctv-switch-sm .ctv-switch-control[aria-checked="true"] .ctv-switch-knob { + left: calc(100% - 14px); +} + +.ctv-checkbox { + gap: 8px; +} + +.ctv-checkbox-control { + width: 17px; + height: 17px; + border-radius: var(--radius-xs); + background: var(--ctv-bg-sunken); + color: var(--text-on-accent); +} + +.ctv-checkbox-control[aria-checked="true"], +.ctv-checkbox-control[aria-checked="mixed"] { + border-color: transparent; + background: var(--action-primary); +} + +.ctv-badge, +.ctv-tag { + display: inline-flex; + align-items: center; + white-space: nowrap; +} + +.ctv-badge { + gap: 5px; + height: 20px; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-xs); + background: var(--ctv-surface-3); + color: var(--text-secondary); + padding: 0 8px; + font-size: var(--text-2xs); + font-weight: var(--weight-medium); + letter-spacing: 0.02em; + line-height: 1; +} + +.ctv-badge-dot { + padding-left: 6px; +} + +.ctv-badge-mark { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.ctv-tone-accent { + border-color: color-mix(in srgb, var(--action-primary) 35%, transparent); + background: var(--ctv-accent-soft); + color: var(--action-primary); +} + +.ctv-tone-ok { + border-color: rgba(63, 185, 132, 0.35); + background: var(--ctv-ok-soft); + color: var(--status-ok); +} + +.ctv-tone-warn { + border-color: rgba(224, 168, 61, 0.35); + background: var(--ctv-warn-soft); + color: var(--status-warn); +} + +.ctv-tone-error { + border-color: rgba(229, 72, 77, 0.35); + background: var(--ctv-error-soft); + color: var(--status-error); +} + +.ctv-badge-solid { + border-color: transparent; + background: currentColor; +} + +.ctv-badge-solid > span:not(.ctv-badge-mark) { + color: #fff; +} + +.ctv-status-dot { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--text-secondary); + font-size: var(--text-xs); +} + +.ctv-status-dot-mark { + position: relative; + display: inline-flex; + flex: 0 0 auto; + width: var(--ctv-dot-size, 8px); + height: var(--ctv-dot-size, 8px); +} + +.ctv-status-dot-core, +.ctv-status-dot-ping { + position: absolute; + inset: 0; + border-radius: 50%; + background: var(--ctv-dot-color, var(--text-disabled)); +} + +.ctv-status-dot-core { + box-shadow: var(--ctv-dot-shadow, none); +} + +.ctv-status-dot-ping { + animation: ctv-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +.ctv-tag { + gap: 6px; + height: 22px; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-xs); + background: var(--ctv-surface-3); + color: var(--text-primary); + padding: 0 9px; + font-size: var(--text-xs); + font-weight: var(--weight-medium); +} + +.ctv-tag-accent { + border-color: color-mix(in srgb, var(--action-primary) 35%, transparent); + background: var(--ctv-accent-soft); + color: var(--action-primary); +} + +.ctv-tag-removable { + padding-right: 5px; +} + +.ctv-tag-icon { + display: inline-flex; + color: var(--text-disabled); +} + +.ctv-tag-remove, +.ctv-toast-close { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: var(--text-disabled); + cursor: pointer; +} + +.ctv-tag-remove { + width: 15px; + height: 15px; + border-radius: 3px; + padding: 0; +} + +.ctv-tag-remove:hover, +.ctv-toast-close:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); +} + +.ctv-channel-logo { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + overflow: hidden; + border: 1px solid var(--border-hairline); + background: var(--ctv-bg-sunken); +} + +.ctv-channel-logo img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.ctv-channel-logo-initials { + font-family: var(--font-mono); + font-weight: var(--weight-semibold); + letter-spacing: 0.02em; +} + +.ctv-card { + overflow: hidden; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md); + background: var(--surface-card); +} + +.ctv-card-inset { + background: var(--ctv-bg-sunken); +} + +.ctv-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--border-hairline); + padding: 13px 16px; +} + +.ctv-card-title { + color: var(--text-primary); + font-size: var(--text-md); + font-weight: var(--weight-semibold); + line-height: 1.2; +} + +.ctv-card-subtitle { + margin-top: 3px; + color: var(--text-secondary); + font-size: var(--text-xs); + line-height: 1.3; +} + +.ctv-card-actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 8px; +} + +.ctv-card-body { + padding: 16px; +} + +.ctv-card-body-flush { + padding: 0; +} + +.ctv-stat { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ctv-stat-label { + display: flex; + align-items: center; + gap: 7px; + color: var(--text-secondary); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + letter-spacing: 0.03em; + line-height: 1; +} + +.ctv-stat-icon { + display: inline-flex; + color: var(--text-disabled); +} + +.ctv-stat-value-row { + display: flex; + align-items: baseline; + gap: 6px; +} + +.ctv-stat-value { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--text-2xl); + font-weight: var(--weight-semibold); + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.ctv-stat-unit { + color: var(--text-disabled); + font-size: var(--text-sm); +} + +.ctv-stat-delta { + margin-left: 2px; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: var(--weight-medium); +} + +.ctv-stat-delta-up { + color: var(--status-ok); +} + +.ctv-stat-delta-down { + color: var(--status-error); +} + +.ctv-progress { + display: flex; + align-items: center; + gap: 10px; +} + +.ctv-progress-track { + position: relative; + flex: 1; + height: var(--ctv-progress-height, 6px); + overflow: hidden; + border-radius: var(--radius-pill); + background: var(--ctv-surface-3); +} + +.ctv-progress-fill { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: var(--ctv-progress-value, 0%); + border-radius: inherit; + background: var(--ctv-progress-color, var(--action-primary)); + transition: width var(--dur-slow) var(--ease-out); +} + +.ctv-progress-fill-indeterminate { + width: 40%; + animation: ctv-indeterminate 1.1s var(--ease-standard) infinite; +} + +.ctv-progress-label { + min-width: 34px; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.ctv-toast { + display: flex; + align-items: flex-start; + gap: 11px; + width: min(380px, 100%); + border: 1px solid var(--border-hairline); + border-left: 2px solid var(--ctv-toast-color, var(--action-primary)); + border-radius: var(--radius-md); + background: var(--surface-raised); + box-shadow: var(--shadow-md); + padding: 12px 13px; +} + +.ctv-toast-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 22px; + height: 22px; + margin-top: 1px; + border-radius: var(--radius-xs); + background: var(--ctv-toast-bg, var(--ctv-accent-soft)); + color: var(--ctv-toast-color, var(--action-primary)); +} + +.ctv-toast-body { + flex: 1; + min-width: 0; +} + +.ctv-toast-title { + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + line-height: 1.3; +} + +.ctv-toast-message { + margin-top: 2px; + color: var(--text-secondary); + font-size: var(--text-xs); + line-height: 1.45; +} + +.ctv-toast-close { + flex: 0 0 auto; + border-radius: var(--radius-xs); + padding: 3px; +} + +.ctv-tooltip-wrap { + position: relative; + display: inline-flex; +} + +.ctv-tooltip { + position: absolute; + z-index: 50; + border: 1px solid var(--border-control); + border-radius: var(--radius-sm); + background: var(--ctv-surface-3); + box-shadow: var(--shadow-pop); + color: var(--text-primary); + padding: 5px 9px; + font-size: var(--text-xs); + font-weight: var(--weight-medium); + line-height: 1.2; + pointer-events: none; + white-space: nowrap; +} + +.ctv-tooltip-top { + bottom: calc(100% + 7px); + left: 50%; + transform: translateX(-50%); +} + +.ctv-tooltip-bottom { + top: calc(100% + 7px); + left: 50%; + transform: translateX(-50%); +} + +.ctv-tooltip-left { + top: 50%; + right: calc(100% + 7px); + transform: translateY(-50%); +} + +.ctv-tooltip-right { + top: 50%; + left: calc(100% + 7px); + transform: translateY(-50%); +} + +.ctv-nav-item { + position: relative; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 34px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + padding: 0 10px; + text-align: left; + text-decoration: none; + transition: + background var(--dur-fast) var(--ease-standard), + color var(--dur-fast) var(--ease-standard); +} + +.ctv-nav-item:hover, +.ctv-nav-item-active { + background: var(--ctv-accent-soft); + color: var(--text-primary); +} + +.ctv-nav-item-active { + font-weight: var(--weight-medium); +} + +.ctv-nav-rail { + position: absolute; + top: 7px; + bottom: 7px; + left: 0; + width: 2.5px; + border-radius: 2px; + background: var(--action-primary); +} + +.ctv-nav-icon { + display: inline-flex; + flex: 0 0 auto; + color: currentColor; +} + +.ctv-nav-item-active .ctv-nav-icon { + color: var(--action-primary); +} + +.ctv-nav-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-nav-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 17px; + height: 17px; + border-radius: var(--radius-pill); + background: var(--status-warn); + color: #1a1206; + padding: 0 5px; + font-family: var(--font-mono); + font-size: var(--text-2xs); + font-weight: var(--weight-semibold); + line-height: 1; +} + +.ctv-nav-badge-error, +.ctv-nav-badge-accent { + color: #fff; +} + +.ctv-nav-badge-error { + background: var(--status-error); +} + +.ctv-nav-badge-accent { + background: var(--action-primary); +} + +.ctv-nav-section { + padding: 14px 10px 6px; + color: var(--text-disabled); + font-size: var(--text-2xs); + font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-caps); + line-height: 1; + text-transform: uppercase; +} + +.ctv-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border-hairline); +} + +.ctv-tab { + position: relative; + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + padding: 0 12px; + transition: color var(--dur-fast) var(--ease-standard); +} + +.ctv-tab:hover, +.ctv-tab[aria-selected="true"] { + color: var(--text-primary); +} + +.ctv-tab[aria-selected="true"] { + font-weight: var(--weight-medium); +} + +.ctv-tab-icon { + display: inline-flex; + color: currentColor; +} + +.ctv-tab[aria-selected="true"] .ctv-tab-icon { + color: var(--action-primary); +} + +.ctv-tab-count { + border-radius: var(--radius-xs); + background: var(--ctv-surface-3); + color: var(--text-disabled); + padding: 2px 5px; + font-family: var(--font-mono); + font-size: var(--text-2xs); + line-height: 1; +} + +.ctv-tab::after { + position: absolute; + right: 6px; + bottom: -1px; + left: 6px; + height: 2px; + border-radius: 2px; + background: transparent; + content: ""; + transition: background var(--dur-fast) var(--ease-standard); +} + +.ctv-tab[aria-selected="true"]::after { + background: var(--action-primary); +} + +@keyframes ctv-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes ctv-ping { + 0% { + opacity: 0.55; + transform: scale(1); + } + + 70%, + 100% { + opacity: 0; + transform: scale(2.4); + } +} + +@keyframes ctv-indeterminate { + 0% { + left: -40%; + } + + 100% { + left: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .ctv-spinner svg, + .ctv-status-dot-ping, + .ctv-progress-fill-indeterminate { + animation: none; + } +} diff --git a/web/src/components/dataDisplay.tsx b/web/src/components/dataDisplay.tsx new file mode 100644 index 000000000..169629543 --- /dev/null +++ b/web/src/components/dataDisplay.tsx @@ -0,0 +1,327 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { X } from 'lucide-react'; + +function classNames(...values: Array): string { + return values.filter(Boolean).join(' '); +} + +export interface BadgeProps { + children?: ReactNode; + tone?: 'neutral' | 'accent' | 'ok' | 'warn' | 'error'; + solid?: boolean; + dot?: boolean; + style?: CSSProperties; +} + +export function Badge({ + children, + tone = 'neutral', + solid = false, + dot = false, + style +}: BadgeProps) { + return ( + + {dot && } + {children} + + ); +} + +export interface StatusDotProps { + status?: 'live' | 'ok' | 'warn' | 'error' | 'idle'; + pulse?: boolean; + size?: number; + label?: string; + style?: CSSProperties; +} + +const statusColors: Record, string> = { + live: 'var(--status-live)', + ok: 'var(--status-ok)', + warn: 'var(--status-warn)', + error: 'var(--status-error)', + idle: 'var(--text-disabled)' +}; + +export function StatusDot({ + status = 'idle', + pulse = false, + size = 8, + label, + style +}: StatusDotProps) { + const color = statusColors[status]; + const shouldPulse = pulse || status === 'live'; + + return ( + + + {shouldPulse && } + + + {label && {label}} + + ); +} + +export interface TagProps { + children?: ReactNode; + onRemove?: () => void; + icon?: ReactNode; + tone?: 'neutral' | 'accent'; + style?: CSSProperties; +} + +export function Tag({ children, onRemove, icon, tone = 'neutral', style }: TagProps) { + return ( + + {icon && {icon}} + {children} + {onRemove && ( + + + + )} + + ); +} + +export interface ChannelLogoProps { + name?: string; + src?: string | null; + size?: number; + radius?: string; + style?: CSSProperties; +} + +const logoPalette = [ + ['#5B7CFA', '#2E3A66'], + ['#3FB984', '#1E4536'], + ['#E0A83D', '#4A3818'], + ['#E5484D', '#4A1F21'], + ['#B06CF0', '#38235A'], + ['#48B0C8', '#193E47'] +] as const; + +function hashIndex(value: string): number { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + + return hash % logoPalette.length; +} + +function initialsFor(name: string): string { + const initials = name + .split(/[\s\-|:]+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase(); + + return initials || '?'; +} + +export function ChannelLogo({ + name = '', + src = null, + size = 40, + radius = 'var(--radius-sm)', + style +}: ChannelLogoProps) { + const [foreground, background] = logoPalette[hashIndex(name)]; + + return ( + + {src ? ( + + ) : ( + + {initialsFor(name)} + + )} + + ); +} + +export interface CardProps { + title?: ReactNode; + subtitle?: ReactNode; + actions?: ReactNode; + children?: ReactNode; + padded?: boolean; + inset?: boolean; + style?: CSSProperties; +} + +export function Card({ + title, + subtitle, + actions, + children, + padded = true, + inset = false, + style +}: CardProps) { + return ( + + {(title || subtitle || actions) && ( + + + {title && {title}} + {subtitle && {subtitle}} + + {actions && {actions}} + + )} + + {children} + + + ); +} + +export interface StatProps { + label: string; + value: ReactNode; + unit?: string; + delta?: ReactNode; + deltaTone?: 'up' | 'down' | 'neutral'; + icon?: ReactNode; + style?: CSSProperties; +} + +export function Stat({ + label, + value, + unit, + delta = null, + deltaTone = 'neutral', + icon, + style +}: StatProps) { + return ( + + + {icon && {icon}} + {label} + + + {value} + {unit && {unit}} + {delta != null && ( + + {delta} + + )} + + + ); +} + +export interface ProgressBarProps { + value?: number | null; + tone?: 'accent' | 'ok' | 'warn' | 'error'; + height?: number; + showLabel?: boolean; + style?: CSSProperties; +} + +const progressColors: Record, string> = { + accent: 'var(--action-primary)', + ok: 'var(--status-ok)', + warn: 'var(--status-warn)', + error: 'var(--status-error)' +}; + +export function ProgressBar({ + value = null, + tone = 'accent', + height = 6, + showLabel = false, + style +}: ProgressBarProps) { + const indeterminate = value == null; + const percent = Math.max(0, Math.min(100, value ?? 0)); + + return ( + + + + + {showLabel && !indeterminate && {Math.round(percent)}%} + + ); +} diff --git a/web/src/components/feedback.tsx b/web/src/components/feedback.tsx new file mode 100644 index 000000000..7fcf98015 --- /dev/null +++ b/web/src/components/feedback.tsx @@ -0,0 +1,107 @@ +import { useState, type CSSProperties, type ReactNode } from 'react'; +import { Check, Info, X } from 'lucide-react'; + +function classNames(...values: Array): string { + return values.filter(Boolean).join(' '); +} + +export interface SpinnerProps { + size?: number; + tone?: 'accent' | 'muted' | 'ok'; + stroke?: number; + style?: CSSProperties; +} + +export function Spinner({ size = 18, tone = 'accent', stroke = 2.4, style }: SpinnerProps) { + return ( + + + + + + + ); +} + +export interface ToastProps { + tone?: 'info' | 'ok' | 'warn' | 'error'; + title?: ReactNode; + message?: ReactNode; + onClose?: () => void; + style?: CSSProperties; +} + +const toastStyle: Record, { color: string; bg: string }> = { + info: { color: 'var(--action-primary)', bg: 'var(--ctv-accent-soft)' }, + ok: { color: 'var(--status-ok)', bg: 'var(--ctv-ok-soft)' }, + warn: { color: 'var(--status-warn)', bg: 'var(--ctv-warn-soft)' }, + error: { color: 'var(--status-error)', bg: 'var(--ctv-error-soft)' } +}; + +export function Toast({ tone = 'info', title, message, onClose, style }: ToastProps) { + const toneStyle = toastStyle[tone]; + const Icon = tone === 'ok' ? Check : Info; + + return ( + + + + + + {title && {title}} + {message && {message}} + + {onClose && ( + + + + )} + + ); +} + +export interface TooltipProps { + label: ReactNode; + placement?: 'top' | 'bottom' | 'left' | 'right'; + children: ReactNode; + style?: CSSProperties; +} + +export function Tooltip({ label, placement = 'top', children, style }: TooltipProps) { + const [visible, setVisible] = useState(false); + + return ( + setVisible(true)} + onMouseLeave={() => setVisible(false)} + onFocus={() => setVisible(true)} + onBlur={() => setVisible(false)} + > + {children} + {visible && label && ( + + {label} + + )} + + ); +} diff --git a/web/src/components/forms.tsx b/web/src/components/forms.tsx new file mode 100644 index 000000000..367126dc1 --- /dev/null +++ b/web/src/components/forms.tsx @@ -0,0 +1,298 @@ +import type { ChangeEvent, CSSProperties, MouseEvent, ReactNode } from 'react'; +import { Check, ChevronDown, Minus } from 'lucide-react'; +import { Spinner } from './feedback'; + +type ControlSize = 'sm' | 'md'; + +function classNames(...values: Array): string { + return values.filter(Boolean).join(' '); +} + +export interface ButtonProps { + children?: ReactNode; + variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; + size?: ControlSize; + startIcon?: ReactNode; + endIcon?: ReactNode; + disabled?: boolean; + loading?: boolean; + fullWidth?: boolean; + type?: 'button' | 'submit' | 'reset'; + onClick?: (e: MouseEvent) => void; + style?: CSSProperties; +} + +export function Button({ + children, + variant = 'primary', + size = 'md', + startIcon, + endIcon, + disabled = false, + loading = false, + fullWidth = false, + type = 'button', + onClick, + style +}: ButtonProps) { + return ( + + {loading ? : startIcon} + {children} + {!loading && endIcon} + + ); +} + +export interface IconButtonProps { + children?: ReactNode; + size?: ControlSize; + variant?: 'ghost' | 'solid'; + active?: boolean; + disabled?: boolean; + title?: string; + onClick?: (e: MouseEvent) => void; + style?: CSSProperties; +} + +export function IconButton({ + children, + size = 'md', + variant = 'ghost', + active = false, + disabled = false, + title, + onClick, + style +}: IconButtonProps) { + return ( + + {children} + + ); +} + +export interface InputProps { + label?: string; + value?: string; + onChange?: (e: ChangeEvent) => void; + placeholder?: string; + leadingIcon?: ReactNode; + trailing?: ReactNode; + error?: string | null; + disabled?: boolean; + size?: ControlSize; + type?: string; + fullWidth?: boolean; + style?: CSSProperties; +} + +export function Input({ + label, + value, + onChange, + placeholder, + leadingIcon, + trailing, + error = null, + disabled = false, + size = 'md', + type = 'text', + fullWidth = true, + style +}: InputProps) { + return ( + + {label && {label}} + + {leadingIcon && {leadingIcon}} + + {trailing && {trailing}} + + {error && {error}} + + ); +} + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps { + label?: string; + value?: string; + onChange?: (e: ChangeEvent) => void; + options?: Array; + disabled?: boolean; + size?: ControlSize; + fullWidth?: boolean; + style?: CSSProperties; +} + +export function Select({ + label, + value, + onChange, + options = [], + disabled = false, + size = 'md', + fullWidth = true, + style +}: SelectProps) { + const normalizedOptions = options.map((option) => + typeof option === 'string' ? { value: option, label: option } : option + ); + + return ( + + {label && {label}} + + + {normalizedOptions.map((option) => ( + + {option.label} + + ))} + + + + + ); +} + +export interface SwitchProps { + checked?: boolean; + onChange?: (next: boolean) => void; + label?: string; + disabled?: boolean; + size?: ControlSize; + style?: CSSProperties; +} + +export function Switch({ + checked = false, + onChange, + label, + disabled = false, + size = 'md', + style +}: SwitchProps) { + return ( + + { + if (!disabled) { + onChange?.(!checked); + } + }} + > + + + {label && {label}} + + ); +} + +export interface CheckboxProps { + checked?: boolean; + onChange?: (next: boolean) => void; + label?: string; + indeterminate?: boolean; + disabled?: boolean; + style?: CSSProperties; +} + +export function Checkbox({ + checked = false, + onChange, + label, + indeterminate = false, + disabled = false, + style +}: CheckboxProps) { + return ( + + { + if (!disabled) { + onChange?.(!checked); + } + }} + > + {indeterminate ? ( + + ) : checked ? ( + + ) : null} + + {label && {label}} + + ); +} diff --git a/web/src/components/index.ts b/web/src/components/index.ts new file mode 100644 index 000000000..363c9365a --- /dev/null +++ b/web/src/components/index.ts @@ -0,0 +1,6 @@ +import './components.css'; + +export * from './dataDisplay'; +export * from './feedback'; +export * from './forms'; +export * from './navigation'; diff --git a/web/src/components/navigation.tsx b/web/src/components/navigation.tsx new file mode 100644 index 000000000..bfaec8744 --- /dev/null +++ b/web/src/components/navigation.tsx @@ -0,0 +1,123 @@ +import type { CSSProperties, MouseEvent, ReactNode } from 'react'; + +function classNames(...values: Array): string { + return values.filter(Boolean).join(' '); +} + +export interface NavItemProps { + icon?: ReactNode; + label: string; + active?: boolean; + badge?: ReactNode; + badgeTone?: 'warn' | 'error' | 'accent'; + href?: string; + onClick?: (e: MouseEvent) => void; + style?: CSSProperties; +} + +export function NavItem({ + icon, + label, + active = false, + badge = null, + badgeTone = 'warn', + href, + onClick, + style +}: NavItemProps) { + const content = ( + <> + {active && } + {icon && {icon}} + {label} + {badge != null && ( + {badge} + )} + > + ); + + if (href) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} + +export interface NavSectionProps { + children?: ReactNode; + style?: CSSProperties; +} + +export function NavSection({ children, style }: NavSectionProps) { + return ( + + {children} + + ); +} + +export interface TabItem { + value: string; + label: string; + icon?: ReactNode; + count?: number; +} + +export interface TabsProps { + tabs: Array; + value?: string; + onChange?: (value: string) => void; + style?: CSSProperties; +} + +function normalizeTab(tab: string | TabItem): TabItem { + return typeof tab === 'string' ? { value: tab, label: tab } : tab; +} + +export function Tabs({ tabs, value, onChange, style }: TabsProps) { + const items = tabs.map(normalizeTab); + const activeValue = value ?? items[0]?.value; + + return ( + + {items.map((tab) => { + const selected = tab.value === activeValue; + + return ( + onChange?.(tab.value)} + > + {tab.icon && {tab.icon}} + {tab.label} + {tab.count != null && {tab.count}} + + ); + })} + + ); +} diff --git a/web/src/shell.css b/web/src/shell.css index 1d352dc5b..c78ed0326 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -254,6 +254,93 @@ line-height: 1; } +.ctv-gallery { + margin-top: var(--space-9, 24px); +} + +.ctv-gallery-heading { + margin-bottom: var(--space-7, 16px); +} + +.ctv-gallery-heading p { + margin: 0 0 var(--space-2, 4px); + color: var(--text-secondary, #a89e92); + font-size: var(--text-xs, 12px); + font-weight: var(--weight-semibold, 600); + letter-spacing: var(--tracking-caps, 0.06em); + text-transform: uppercase; +} + +.ctv-gallery-heading h2 { + margin: 0; + font-size: var(--text-xl, 20px); + line-height: var(--leading-tight, 1.2); +} + +.ctv-gallery-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-7, 16px); +} + +.ctv-card-title h3 { + margin: 0; + font-size: var(--text-md, 14px); + line-height: 1.2; +} + +.ctv-demo-stack { + display: flex; + flex-direction: column; + gap: var(--space-7, 16px); +} + +.ctv-demo-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-5, 10px); +} + +.ctv-demo-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-7, 16px); +} + +.ctv-stat-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-8, 20px); +} + +.ctv-demo-progress { + display: flex; + flex-direction: column; + gap: var(--space-6, 12px); +} + +.ctv-feedback-grid { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto; + gap: var(--space-7, 16px); + align-items: start; +} + +.ctv-navigation-demo { + display: grid; + grid-template-columns: 216px minmax(0, 1fr); + gap: var(--space-8, 20px); + align-items: start; +} + +.ctv-navigation-panel { + border: 1px solid var(--border-hairline, #2e2823); + border-radius: var(--radius-md, 7px); + background: var(--surface-card, #1c1917); + padding: var(--space-3, 6px); +} + @media (max-width: 720px) { .ctv-app-shell { grid-template-columns: 1fr; @@ -281,6 +368,13 @@ .ctv-theme-switcher { justify-content: flex-start; } + + .ctv-gallery-grid, + .ctv-demo-fields, + .ctv-feedback-grid, + .ctv-navigation-demo { + grid-template-columns: 1fr; + } } @media (prefers-reduced-motion: reduce) {
Foundation
Issue #80