feat(web): Storybook for offline UI design + light theme + brand spinner

Stand up Storybook so the management console can be designed without a running
host, plus the design-system work that surfaced along the way.

Storybook (@storybook/react-vite):
- Slim Start/Nitro-free vite config; the preview imports the app's real
  src/styles.css directly so the design tokens stay single-sourced (no mirror).
- Stories for the @unom/ui primitives (Button/Card/Inputs/Badge), brand marks,
  the AppShell (throwaway in-memory TanStack router), and every data-driven page
  (Dashboard/Host/Clients/Library/Settings) rendered offline via a window.fetch
  stub + typed fixtures. The route page components are exported so stories can
  render them.

Light theme:
- styles.css now carries a light :root (lavender, from the docs palette) with the
  existing violet chrome moved to .dark; the live console still pins html.dark by
  default, so this only adds the option (Storybook's toolbar toggles it).
- Fixes a stray `*/` inside a comment that prematurely closed it and silently
  broke Tailwind's @theme processing.

Spinner:
- The punktfunk lens recreated with motion/react: two circles surge through one
  another in depth (JS perspective scale + z-index — robust where mix-blend-mode
  flattens CSS preserve-3d) with a screen-blend lens highlight. Replaces the
  skeleton loading state in QueryState; removes ui/skeleton.tsx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 21:58:36 +00:00
parent 0255a8289c
commit 75ee53d1dd
30 changed files with 1164 additions and 246 deletions
+62
View File
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router'
import { AppShell } from '@/components/app-shell'
// AppShell is built from TanStack Router <Link>s, so it needs a router context.
// We stand up a throwaway in-memory router whose routes mirror the nav targets
// (so links resolve + the active highlight works) and render the shell from the
// root route. No loaders/data — purely for designing the chrome offline.
function ShellHarness({ initialPath }: { initialPath: string }) {
const rootRoute = createRootRoute({
component: () => (
<AppShell>
<div className="space-y-3">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Placeholder content swap routes from the sidebar to preview the active state.
</p>
</div>
</AppShell>
),
})
const navPaths = ['/', '/host', '/library', '/clients', '/pairing', '/settings']
const navRoutes = navPaths.map((path) =>
createRoute({ getParentRoute: () => rootRoute, path, component: () => null }),
)
// Splat so any other <Link> target still resolves without throwing.
const splat = createRoute({ getParentRoute: () => rootRoute, path: '$', component: () => null })
const router = createRouter({
routeTree: rootRoute.addChildren([...navRoutes, splat]),
history: createMemoryHistory({ initialEntries: [initialPath] }),
})
return <RouterProvider router={router} />
}
const meta = {
title: 'Shell/AppShell',
component: AppShell,
parameters: { layout: 'fullscreen' },
// AppShell requires `children`; the harness supplies the real content, so this
// placeholder just satisfies the arg type.
args: { children: null },
} satisfies Meta<typeof AppShell>
export default meta
type Story = StoryObj<typeof meta>
export const Dashboard: Story = {
render: () => <ShellHarness initialPath="/" />,
}
export const HostActive: Story = {
render: () => <ShellHarness initialPath="/host" />,
}
+30
View File
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Badge } from '@/components/ui/badge'
const VARIANTS = ['default', 'secondary', 'success', 'destructive', 'outline'] as const
const meta = {
title: 'UI/Badge',
component: Badge,
args: { children: 'badge' },
argTypes: {
variant: { control: 'select', options: VARIANTS },
},
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const All: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-2">
{VARIANTS.map((variant) => (
<Badge key={variant} variant={variant}>
{variant}
</Badge>
))}
</div>
),
}
+40
View File
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { BrandMark } from '@/components/brand-mark'
import { Wordmark } from '@/components/wordmark'
import { Logo } from '@/components/logo'
const meta = {
title: 'Brand/Marks',
component: BrandMark,
} satisfies Meta<typeof BrandMark>
export default meta
type Story = StoryObj<typeof meta>
export const Mark: Story = {
render: () => (
<div className="flex items-end gap-6">
<BrandMark className="size-8" />
<BrandMark className="size-12" />
<BrandMark className="size-20" />
</div>
),
}
export const Word: Story = {
render: () => (
<div className="space-y-4">
<Wordmark className="h-4" />
<Wordmark className="h-6 text-foreground" />
<Wordmark className="h-8 text-primary" />
</div>
),
}
export const Lockup: Story = {
render: () => (
<div className="pl-8 pt-6">
<Logo className="w-48" />
</div>
),
}
+48
View File
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
const VARIANTS = ['default', 'secondary', 'outline', 'ghost', 'link', 'destructive'] as const
const SIZES = ['default', 'sm', 'lg', 'icon'] as const
const meta = {
title: 'UI/Button',
component: Button,
args: { children: 'Stream' },
argTypes: {
variant: { control: 'select', options: VARIANTS },
size: { control: 'select', options: SIZES },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
/** Playground — drive variant/size/disabled from the Controls panel. */
export const Playground: Story = {}
export const Variants: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-3">
{VARIANTS.map((variant) => (
<Button key={variant} variant={variant}>
{variant}
</Button>
))}
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-3">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon" aria-label="Play">
<Play className="size-4" />
</Button>
</div>
),
}
+45
View File
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
const meta = {
title: 'UI/Card',
component: Card,
// Card requires `children`; every story supplies its own via `render`, so this
// is just a placeholder to satisfy the arg type.
args: { children: null },
} satisfies Meta<typeof Card>
export default meta
type Story = StoryObj<typeof meta>
export const HostCard: Story = {
render: () => (
<Card className="max-w-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>ENRICOS-DESKTOP</CardTitle>
<Badge variant="success">online</Badge>
</div>
<CardDescription>RTX 5070 Ti · NVENC · 5120×1440 @ 240</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Paired 2 days ago. Last session 11 ms p50 capturepresent.
</CardContent>
<CardFooter className="gap-2">
<Button size="sm">Connect</Button>
<Button size="sm" variant="outline">
Details
</Button>
</CardFooter>
</Card>
),
}
+28
View File
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { ClientsPage } from '@/routes/clients'
import { MockApi } from './lib/mock-api'
import { pairedClients } from './lib/fixtures'
const meta = {
title: 'Pages/Clients',
component: ClientsPage,
} satisfies Meta<typeof ClientsPage>
export default meta
type Story = StoryObj<typeof meta>
export const Paired: Story = {
render: () => (
<MockApi routes={{ '/api/v1/clients': pairedClients }}>
<ClientsPage />
</MockApi>
),
}
export const Empty: Story = {
render: () => (
<MockApi routes={{ '/api/v1/clients': [] }}>
<ClientsPage />
</MockApi>
),
}
+28
View File
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Dashboard } from '@/routes/index'
import { MockApi } from './lib/mock-api'
import { statusActive, statusIdle } from './lib/fixtures'
const meta = {
title: 'Pages/Dashboard',
component: Dashboard,
} satisfies Meta<typeof Dashboard>
export default meta
type Story = StoryObj<typeof meta>
export const ActiveSession: Story = {
render: () => (
<MockApi routes={{ '/api/v1/status': statusActive }}>
<Dashboard />
</MockApi>
),
}
export const Idle: Story = {
render: () => (
<MockApi routes={{ '/api/v1/status': statusIdle }}>
<Dashboard />
</MockApi>
),
}
+20
View File
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { HostPage } from '@/routes/host'
import { MockApi } from './lib/mock-api'
import { compositors, hostInfo } from './lib/fixtures'
const meta = {
title: 'Pages/Host',
component: HostPage,
} satisfies Meta<typeof HostPage>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<MockApi routes={{ '/api/v1/host': hostInfo, '/api/v1/compositors': compositors }}>
<HostPage />
</MockApi>
),
}
+30
View File
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const meta = {
title: 'UI/Inputs',
component: Input,
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
export const Form: Story = {
render: () => (
<div className="max-w-sm space-y-4">
<div className="space-y-1.5">
<Label htmlFor="host">Host address</Label>
<Input id="host" placeholder="192.168.1.173" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pin">Pairing PIN</Label>
<Input id="pin" inputMode="numeric" maxLength={4} placeholder="0000" />
</div>
<div className="space-y-1.5">
<Label htmlFor="disabled">Disabled</Label>
<Input id="disabled" disabled placeholder="unavailable" />
</div>
</div>
),
}
+28
View File
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { LibraryPage } from '@/routes/library'
import { MockApi } from './lib/mock-api'
import { library } from './lib/fixtures'
const meta = {
title: 'Pages/Library',
component: LibraryPage,
} satisfies Meta<typeof LibraryPage>
export default meta
type Story = StoryObj<typeof meta>
export const Populated: Story = {
render: () => (
<MockApi routes={{ '/api/v1/library': library }}>
<LibraryPage />
</MockApi>
),
}
export const Empty: Story = {
render: () => (
<MockApi routes={{ '/api/v1/library': [] }}>
<LibraryPage />
</MockApi>
),
}
+36
View File
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { QueryState } from '@/components/query-state'
import { ApiError } from '@/api/fetcher'
// QueryState is the uniform loading/error wrapper every data-backed route uses —
// the most useful thing to design WITHOUT a running host, since its three states
// (loading spinner / error / unauthorized) never appear together live.
const Loaded = () => (
<div className="rounded-lg border p-4 text-sm">Loaded content renders here.</div>
)
const meta = {
title: 'Patterns/QueryState',
component: QueryState,
args: { children: <Loaded /> },
} satisfies Meta<typeof QueryState>
export default meta
type Story = StoryObj<typeof meta>
export const Loading: Story = {
args: { isLoading: true, error: null },
}
export const ErrorWithRetry: Story = {
args: { isLoading: false, error: new Error('connection refused'), refetch: () => {} },
}
export const Unauthorized: Story = {
args: { isLoading: false, error: new ApiError(401, null) },
}
export const Loaded_: Story = {
name: 'Success',
args: { isLoading: false, error: null },
}
+14
View File
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { SettingsPage } from '@/routes/settings'
// Settings reads no API (just the locale + a logout button), so it renders
// directly — no mock needed.
const meta = {
title: 'Pages/Settings',
component: SettingsPage,
} satisfies Meta<typeof SettingsPage>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
+31
View File
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Spinner } from '@/components/ui/spinner'
const meta = {
title: 'UI/Spinner',
component: Spinner,
} satisfies Meta<typeof Spinner>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Large: Story = {
render: () => (
<div className="flex min-h-60 items-center justify-center">
<Spinner className="size-40" />
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Spinner className="size-4" />
<Spinner className="size-6" />
<Spinner className="size-10" />
<Spinner className="size-10 text-primary" />
</div>
),
}
+87
View File
@@ -0,0 +1,87 @@
// Mock API payloads for the page stories — typed against the generated models so
// they stay honest if the OpenAPI schema changes.
import type { AvailableCompositor } from '@/api/gen/model/availableCompositor'
import type { GameEntry } from '@/api/gen/model/gameEntry'
import type { HostInfo } from '@/api/gen/model/hostInfo'
import type { PairedClient } from '@/api/gen/model/pairedClient'
import type { RuntimeStatus } from '@/api/gen/model/runtimeStatus'
export const hostInfo: HostInfo = {
abi_version: 2,
app_version: '7.1.450.0',
codecs: ['h264', 'h265', 'av1'],
gfe_version: '3.23.0.74',
hostname: 'ENRICOS-DESKTOP',
local_ip: '192.168.1.173',
ports: {
audio: 48000,
control: 47999,
http: 47989,
https: 47984,
mgmt: 47990,
rtsp: 48010,
video: 47998,
},
uniqueid: '0f8a1c3e9b7d4a62',
version: '0.2.0',
}
export const compositors: AvailableCompositor[] = [
{ id: 'kwin', label: 'KWin (Plasma)', available: true, default: true },
{ id: 'gamescope', label: 'gamescope', available: true, default: false },
{ id: 'mutter', label: 'Mutter (GNOME)', available: false, default: false },
{ id: 'wlroots', label: 'Sway / wlroots', available: false, default: false },
]
export const statusActive: RuntimeStatus = {
video_streaming: true,
audio_streaming: true,
paired_clients: 3,
pin_pending: false,
session: { width: 5120, height: 1440, fps: 240 },
stream: {
codec: 'h265',
width: 5120,
height: 1440,
fps: 240,
bitrate_kbps: 150_000,
min_fec: 5,
packet_size: 1392,
},
}
export const statusIdle: RuntimeStatus = {
video_streaming: false,
audio_streaming: false,
paired_clients: 1,
pin_pending: true,
session: null,
stream: null,
}
export const pairedClients: PairedClient[] = [
{
fingerprint: 'a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00',
subject: 'enricos-macbook',
not_before_unix: 1_718_000_000,
not_after_unix: 2_030_000_000,
},
{
fingerprint: 'ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1',
subject: 'living-room-tv',
not_before_unix: 1_718_500_000,
not_after_unix: 2_030_000_000,
},
{
fingerprint: '0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff',
subject: null,
},
]
const noArt = { header: null, hero: null, logo: null, portrait: null }
export const library: GameEntry[] = [
{ id: 'steam:1245620', store: 'steam', title: 'Elden Ring', art: noArt, launch: null },
{ id: 'steam:1086940', store: 'steam', title: "Baldur's Gate 3", art: noArt, launch: null },
{ id: 'steam:413150', store: 'steam', title: 'Stardew Valley', art: noArt, launch: null },
{ id: 'custom:retroarch', store: 'custom', title: 'RetroArch', art: noArt, launch: null },
]
+49
View File
@@ -0,0 +1,49 @@
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
/** Map of API pathname (e.g. `/api/v1/host`) → JSON body to return for a GET. */
export type MockRoutes = Record<string, unknown>
/**
* Renders a data-backed page WITHOUT a running host by stubbing `window.fetch`
* for the lifetime of the story: matched pathnames return their mock JSON (200),
* everything else returns `{}` (200) so mutations + polling never error. The
* real orval/React-Query hooks run unchanged, so loading/success transitions and
* `refetchInterval` behave exactly as in the app. Each story gets a fresh,
* isolated QueryClient (retries off).
*/
export function MockApi({ routes, children }: { routes: MockRoutes; children: ReactNode }) {
// Read the latest routes inside the stub without re-installing it.
const routesRef = useRef(routes)
routesRef.current = routes
const [stubbed, setStubbed] = useState(false)
useEffect(() => {
const real = window.fetch
const stub = (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const path = new URL(url, window.location.origin).pathname
const data = path in routesRef.current ? routesRef.current[path] : {}
return Promise.resolve(
new Response(JSON.stringify(data ?? null), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
}
window.fetch = stub as typeof window.fetch
setStubbed(true)
return () => {
window.fetch = real
}
}, [])
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
)
// Hold the first render until the stub is installed, so the page's initial
// query resolves against the mock rather than racing a real (failing) request.
if (!stubbed) return null
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}