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
+7 -4
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { ApiError } from '@/api/fetcher'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
import { Button } from '@/components/ui/button'
import { m } from '@/paraglide/messages'
@@ -15,9 +15,12 @@ interface QueryStateProps {
export function QueryState({ isLoading, error, refetch, children }: QueryStateProps) {
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-24 w-full" />
<div
role="status"
className="flex min-h-40 flex-col items-center justify-center gap-3 text-sm text-muted-foreground"
>
<Spinner className="size-8" />
{m.common_loading()}
</div>
)
}
-7
View File
@@ -1,7 +0,0 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
}
export { Skeleton }
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef } from 'react'
import { motion, useReducedMotion, useTime, useTransform } from 'motion/react'
import { cn } from '@/lib/utils'
// The punktfunk lens, alive. The two overlapping circles of the brand mark are
// recreated from divs and animated as if orbiting on a path whose long axis points
// INTO the screen, so depth is the dominant motion: each circle surges toward and
// away from the viewer in antiphase, passing in front of and behind the other.
//
// The 3D is faked in JS (a perspective `scale()` + a `z-index` derived from depth)
// rather than CSS `preserve-3d` — because `mix-blend-mode` (which gives the lens
// its glowing overlap) flattens a preserve-3d context in some browsers, killing
// both the scaling and the front/back swap. Honours prefers-reduced-motion.
// Size via className (e.g. `size-8`); geometry derives from the box.
const DURATION_MS = 1600
const R_DEPTH = 0.34 // depth amplitude (fraction of box) → the size change
const PERSP = 1.05 // perspective distance (fraction of box); smaller → stronger scaling
const R_PLANE_FIXED = 0.12 // constant in-plane offset → the two never fully eclipse
const R_PLANE_SWAY = 0.05 // small in-plane breathing
const DIAG: readonly [number, number] = [-Math.SQRT1_2, Math.SQRT1_2] // lens axis (↙ light / ↗ deep)
const LOBE_FRAC = 0.58 // circle diameter as a fraction of the box
const REST = 0 // reduced-motion: park flat (widest lens, no depth) = the brand mark
export function Spinner({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
const reduce = useReducedMotion()
const ref = useRef<HTMLDivElement>(null)
const sizeRef = useRef(0)
const time = useTime()
useEffect(() => {
const el = ref.current
if (!el) return
sizeRef.current = el.clientWidth
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width
if (w) sizeRef.current = w
})
ro.observe(el)
return () => ro.disconnect()
}, [])
const angleAt = (t: number) => (reduce ? REST : (t / DURATION_MS) * Math.PI * 2)
const depthAt = (t: number, side: number) => side * Math.sin(angleAt(t)) * R_DEPTH
const transformAt = (t: number, side: number) => {
const s = sizeRef.current
const angle = angleAt(t)
const z = side * Math.sin(angle) * R_DEPTH // world depth (toward viewer = +)
const p = PERSP / (PERSP - z) // perspective: nearer → bigger, farther → smaller
const mag = (R_PLANE_FIXED + R_PLANE_SWAY * Math.cos(angle)) * side
const x = mag * DIAG[0] * p * s
const y = mag * DIAG[1] * p * s
return `translate(-50%, -50%) translate(${x}px, ${y}px) scale(${p})`
}
const tLight = useTransform(time, (t) => transformAt(t, 1))
const tDeep = useTransform(time, (t) => transformAt(t, -1))
// z-index follows depth, so whichever circle is nearer is painted on top.
const zLight = useTransform(time, (t) => Math.round(depthAt(t, 1) * 1000))
const zDeep = useTransform(time, (t) => Math.round(depthAt(t, -1) * 1000))
const lobe = (color: string): React.CSSProperties => ({
width: `${LOBE_FRAC * 100}%`,
height: `${LOBE_FRAC * 100}%`,
backgroundColor: color,
mixBlendMode: 'screen',
})
return (
<div
ref={ref}
role="status"
aria-label="Loading"
className={cn('relative inline-block size-6 isolate', className)}
{...props}
>
<motion.div
className="absolute left-1/2 top-1/2 rounded-full"
style={{ ...lobe('var(--pf-brand-light)'), transform: tLight, zIndex: zLight }}
/>
<motion.div
className="absolute left-1/2 top-1/2 rounded-full"
style={{ ...lobe('var(--pf-brand)'), transform: tDeep, zIndex: zDeep }}
/>
</div>
)
}
+2 -1
View File
@@ -22,7 +22,8 @@ import { useLocale } from '@/lib/i18n'
export const Route = createFileRoute('/clients')({ component: ClientsPage })
function ClientsPage() {
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function ClientsPage() {
useLocale()
const qc = useQueryClient()
const clients = useListPairedClients()
+3 -1
View File
@@ -8,7 +8,9 @@ import { useLocale } from '@/lib/i18n'
export const Route = createFileRoute('/host')({ component: HostPage })
function HostPage() {
// Exported so Storybook can render the page directly (see src/stories). The
// route gen only needs the `Route` export; this extra one is harmless.
export function HostPage() {
useLocale()
const host = useGetHostInfo()
const compositors = useListCompositors()
+2 -1
View File
@@ -15,7 +15,8 @@ import { useLocale } from '@/lib/i18n'
export const Route = createFileRoute('/')({ component: Dashboard })
function Dashboard() {
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function Dashboard() {
useLocale()
const qc = useQueryClient()
// Poll live status every 2s so the console tracks an active session.
+2 -1
View File
@@ -66,7 +66,8 @@ function toInput(f: FormState): CustomInput {
}
}
function LibraryPage() {
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function LibraryPage() {
useLocale()
const qc = useQueryClient()
const library = useGetLibrary()
+2 -1
View File
@@ -7,7 +7,8 @@ import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n'
export const Route = createFileRoute('/settings')({ component: SettingsPage })
function SettingsPage() {
// Exported for Storybook (see src/stories) — harmless alongside `Route`.
export function SettingsPage() {
const current = useLocale()
const onLogout = async () => {
+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>
}
+70 -36
View File
@@ -4,55 +4,63 @@
@custom-variant dark (&:is(.dark *));
/* Pull @unom/ui's compiled component classes (bg-neutral, rounded-card,
p-padding-card, ring-accent, h-input-height, material…) into the Tailwind 4
scan so its utilities aren't purged. This is the shared design system the
punktfunk marketing site + docs are built on. */
/* Pull @unom/ui's compiled component classes (bg-neutral,
rounded-card, p-padding-card, ring-accent, h-input-height, material…) into the
Tailwind 4 scan so their utilities aren't purged. */
@source '../node_modules/@unom/ui/dist/**/*.{js,mjs}';
/* ── punktfunk brand · dark violet product chrome ───────────────────────────
The console runs dark-only (html.dark). Surfaces are the violet-tinted
app-icon chrome (#141019 / #1c1530), the brand is the violet lens mark
(#6c5bf3 → #a79ff8 in dark). The token set feeds BOTH @unom/ui's semantic
contract (--brand/--primary/--accent/--neutral/--main…) and the shadcn-style
tokens the console's primitives + routes use (--background/--card/--muted…),
mapped onto one palette so both render the same identity. */
/* ── punktfunk brand · violet product chrome ────────────────────────────────
Two themes on one violet identity: LIGHT (lavender docs surface — the same
palette the docs/Scalar reference uses: white bg, faint-violet cards/borders,
#6c5bf3 brand) is the :root default; DARK (the violet-tinted app-icon chrome
#141019 / #1c1530, #a79ff8 brand) is the `.dark` override. The live console
pins `<html class="dark">` so it stays dark by default — removing or toggling
that class (e.g. Storybook's theme switch) yields light. The token set feeds
BOTH @unom/ui's semantic contract (--brand/--primary/--accent/--neutral/
--main…) and the shadcn-style tokens the console's primitives + routes use
(--background/--card/--muted…), mapped onto one palette so both render the
same identity.
Brand constants and the surface tokens that are pure indirections
(--main, --neutral*, --error → other tokens) live in :root only — CSS resolves
var() per-theme at use time, so .dark overrides just the raw values. */
:root {
--radius: 0.625rem;
/* Brand — the violet lens mark (from the punktfunk app icon). */
--pf-brand: #6c5bf3; /* deep violet */
--pf-brand-light: #a79ff8; /* light violet — reads better on dark */
/* Brand — the violet lens mark (from the punktfunk app icon). Theme-independent. */
--pf-brand: #6c5bf3; /* deep violet — primary on light */
--pf-brand-light: #a79ff8; /* light violet — primary on dark */
--pf-highlight: #d2c9fb; /* lens highlight */
/* Surfaces (violet-tinted dark chrome). */
--background: #141019;
--foreground: oklch(0.985 0 0);
--card: #1c1530;
--card-foreground: oklch(0.985 0 0);
--popover: #1c1530;
--popover-foreground: oklch(0.985 0 0);
--muted: #1f1830;
--muted-foreground: oklch(0.728 0.03 286);
--secondary: #241c3d;
--secondary-foreground: oklch(0.985 0 0);
/* Surfaces — light · lavender (white bg, faint-violet cards/borders). */
--background: #ffffff;
--foreground: #1b1430;
--card: #f6f2ff;
--card-foreground: #1b1430;
--popover: #ffffff;
--popover-foreground: #1b1430;
--muted: #f1ecfd;
--muted-foreground: #6f6a86;
--secondary: #ece6fb;
--secondary-foreground: #1b1430;
/* shadcn `accent` = subtle hover surface; also @unom/ui's card ring colour,
so we tint it toward the brand violet. */
so we tint it toward the brand violet (the same in both themes). */
--accent: var(--pf-brand);
--accent-foreground: oklch(0.985 0 0);
--border: #2a2148;
--input: #2a2148;
--ring: var(--pf-brand-light);
--accent-foreground: #ffffff;
--border: #e4dcf7;
--input: #e4dcf7;
--ring: var(--pf-brand);
/* Primary = the brand (buttons, active nav, default badges). */
--primary: var(--pf-brand-light);
--primary-foreground: #141019;
--primary: var(--pf-brand);
--primary-foreground: #ffffff;
--success: oklch(0.7 0.15 160);
--destructive: oklch(0.62 0.21 18);
--destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.6 0.14 160);
--destructive: oklch(0.55 0.22 18);
--destructive-foreground: #ffffff;
/* ── @unom/ui semantic token contract (its components read these names). ── */
/* ── @unom/ui semantic token contract (its components read these names). ──
These are indirections — they follow the raw tokens above per-theme. */
--main: var(--foreground);
--brand: var(--pf-brand);
--brand-light: var(--pf-brand-light);
@@ -69,6 +77,32 @@
--radius-card-min: var(--radius);
}
/* Dark · the violet-tinted app-icon chrome. Overrides only the raw values —
the indirection tokens in :root resolve to these automatically. */
.dark {
--background: #141019;
--foreground: oklch(0.985 0 0);
--card: #1c1530;
--card-foreground: oklch(0.985 0 0);
--popover: #1c1530;
--popover-foreground: oklch(0.985 0 0);
--muted: #1f1830;
--muted-foreground: oklch(0.728 0.03 286);
--secondary: #241c3d;
--secondary-foreground: oklch(0.985 0 0);
--border: #2a2148;
--input: #2a2148;
--ring: var(--pf-brand-light);
/* Lighter violet reads better against the dark surface. */
--primary: var(--pf-brand-light);
--primary-foreground: #141019;
--success: oklch(0.7 0.15 160);
--destructive: oklch(0.62 0.21 18);
--destructive-foreground: oklch(0.985 0 0);
}
/* Map the palette to Tailwind colour/util tokens — both the shadcn vocabulary
and @unom/ui's, resolved to one set of values. */
@theme inline {