phase 10: add ui system harness
This commit is contained in:
parent
740366a027
commit
5eef2eac37
3 changed files with 229 additions and 0 deletions
5
src/app/(app)/dev/ui/page.tsx
Normal file
5
src/app/(app)/dev/ui/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { UiHarness } from '@/components/dev/UiHarness';
|
||||
|
||||
export default function UiDevPage() {
|
||||
return <UiHarness />;
|
||||
}
|
||||
188
src/components/dev/UiHarness.tsx
Normal file
188
src/components/dev/UiHarness.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { THEMES, useTheme } from '@/contexts/ThemeContext';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CheckItem,
|
||||
Divider,
|
||||
Field,
|
||||
IconButton,
|
||||
Input,
|
||||
Panel,
|
||||
Section,
|
||||
Select,
|
||||
Sidebar,
|
||||
Surface,
|
||||
Switch,
|
||||
Textarea,
|
||||
ToggleRow,
|
||||
Toolbar,
|
||||
ToolbarButton,
|
||||
ToolbarGroup,
|
||||
ToolbarSegment,
|
||||
buttonClass,
|
||||
dropzoneSurfaceClass,
|
||||
popoverPanelClass,
|
||||
rangeInputClass,
|
||||
} from '@/components/ui';
|
||||
|
||||
const THEME_OPTIONS = THEMES.filter((theme) => theme !== 'custom');
|
||||
|
||||
export function UiHarness() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [selectValue, setSelectValue] = useState('library');
|
||||
|
||||
return (
|
||||
<main className="min-h-[calc(100vh-3rem)] bg-background px-4 py-6 text-foreground sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-5">
|
||||
<header className="flex flex-col gap-3 rounded-lg border border-line bg-surface p-4 shadow-elev-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<Badge tone="accent">Authenticated dev surface</Badge>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">UI system harness</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-soft">
|
||||
Review shared primitives, state tokens, and theme behavior from the authenticated app shell.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary">Primary action</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Section
|
||||
title="Theme Matrix"
|
||||
subtitle="Switches the real document theme so token regressions are visible in context."
|
||||
>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-2">
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setTheme(option)}
|
||||
className={buttonClass({
|
||||
variant: theme === option ? 'primary' : 'outline',
|
||||
size: 'sm',
|
||||
className: 'capitalize',
|
||||
})}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<Panel className="space-y-4 p-4" elevation="2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="secondary" disabled>Disabled</Button>
|
||||
<IconButton aria-label="Refresh sample">↻</IconButton>
|
||||
<IconButton aria-label="Danger sample" tone="danger">×</IconButton>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Reader name" hint="Field, input, and hint tokens.">
|
||||
<Input placeholder="OpenReader" />
|
||||
</Field>
|
||||
<Field label="Mode">
|
||||
<Select
|
||||
value={selectValue}
|
||||
onChange={setSelectValue}
|
||||
options={[
|
||||
{ value: 'library', label: 'Library' },
|
||||
{ value: 'reader', label: 'Reader' },
|
||||
{ value: 'player', label: 'Player' },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Notes" className="md:col-span-2">
|
||||
<Textarea rows={3} placeholder="Textarea state preview" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<ToggleRow
|
||||
label="Calm motion"
|
||||
description="Feedback-only transitions, no ambient movement."
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
<Card>
|
||||
<CheckItem label="Parsed text available" checked={checked} onChange={setChecked} />
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-sm text-soft">Direct switch sample</span>
|
||||
<Switch checked={enabled} onChange={setEnabled} ariaLabel="Toggle direct switch sample" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Surface tone="sunken" className="p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">Range control</span>
|
||||
<Badge tone="foreground">1.2x</Badge>
|
||||
</div>
|
||||
<input className={rangeInputClass} type="range" min="0.5" max="3" step="0.1" defaultValue="1.2" />
|
||||
</Surface>
|
||||
</Panel>
|
||||
|
||||
<Sidebar className="min-h-full p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-foreground">Sidebar</span>
|
||||
<Badge>Nav</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{['Library', 'Reader', 'Audio', 'Settings'].map((item, index) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={`w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors duration-fast ease-standard ${
|
||||
index === 0 ? 'bg-accent-wash text-accent' : 'text-soft hover:bg-accent-wash hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
|
||||
<Panel elevation="1">
|
||||
<Toolbar className="static">
|
||||
<ToolbarGroup>
|
||||
<ToolbarSegment active>List</ToolbarSegment>
|
||||
<ToolbarSegment>Grid</ToolbarSegment>
|
||||
<ToolbarSegment>Gallery</ToolbarSegment>
|
||||
</ToolbarGroup>
|
||||
<ToolbarButton active>Sort by name</ToolbarButton>
|
||||
<ToolbarButton>Recently opened</ToolbarButton>
|
||||
</Toolbar>
|
||||
<div className="grid gap-4 p-4 md:grid-cols-3">
|
||||
<div className={dropzoneSurfaceClass({ active: false })}>
|
||||
<p className="text-sm font-medium text-foreground">Dropzone idle</p>
|
||||
<p className="mt-1 text-xs text-soft">Border and wash states live in `dropzoneStyles`.</p>
|
||||
</div>
|
||||
<div className={dropzoneSurfaceClass({ active: true })}>
|
||||
<p className="text-sm font-medium">Dropzone active</p>
|
||||
<p className="mt-1 text-xs">Drag feedback uses tokenized state colors.</p>
|
||||
</div>
|
||||
<div className={popoverPanelClass}>
|
||||
<p className="text-sm font-medium text-foreground">Popover panel</p>
|
||||
<p className="mt-1 text-xs text-soft">Shared shell for player and reader controls.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
|
||||
import { cn } from './cn';
|
||||
|
||||
export const listboxButtonClass =
|
||||
|
|
@ -16,3 +19,36 @@ export const segmentedButtonClass = (active: boolean) =>
|
|||
'rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors duration-fast ease-standard focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
||||
active ? 'bg-accent text-background' : 'text-soft hover:bg-accent-wash hover:text-foreground',
|
||||
);
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
const activeOption = options.find((option) => option.value === value) ?? options[0];
|
||||
|
||||
return (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<span>{activeOption?.label ?? 'Select'}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">v</span>
|
||||
</ListboxButton>
|
||||
<ListboxOptions anchor="bottom" className={listboxOptionsClass}>
|
||||
{options.map((option) => (
|
||||
<ListboxOption key={option.value} value={option.value} className={({ active }) => listboxOptionClass(active)}>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="absolute left-2 text-accent">{selected ? '*' : ''}</span>
|
||||
<span>{option.label}</span>
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue