Scaffold design engineering playground

Next.js 16 + TypeScript + Tailwind v4 + Motion project showcasing
interface components built on semantic design tokens:

- Light/dark/system theming from one CSS variable set, with a
  pre-paint script so a saved theme never flashes
- Button, Switch and SegmentedControl with keyboard support, ARIA
  semantics and spring motion that respects reduced-motion
- Vitest + Testing Library tests, including a token-coverage test
- /tokens page documenting colour and motion tokens

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-09 03:52:46 +00:00
co-authored by Claude Opus 5.5
parent 3d9c5cef51
commit 7af2315316
33 changed files with 9526 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+90
View File
@@ -0,0 +1,90 @@
@import "tailwindcss";
/*
* Design tokens. These are the only place colours are written down.
* Components use the semantic names (bg-surface, text-muted, ...).
* They never use raw palette values.
* src/tokens/tokens.test.ts checks that every token in tokens.ts is defined
* here for both themes.
*/
:root,
[data-theme="light"] {
color-scheme: light;
--background: #fafafa;
--surface: #ffffff;
--surface-hover: #f2f2f3;
--border: #e4e4e7;
--foreground: #18181b;
--muted: #71717a;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-foreground: #ffffff;
--danger: #dc2626;
--ring: #6366f1;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--background: #09090b;
--surface: #131316;
--surface-hover: #1c1c21;
--border: #2a2a30;
--foreground: #f4f4f5;
--muted: #a1a1aa;
--accent: #818cf8;
--accent-hover: #a5b4fc;
--accent-foreground: #0b0b1a;
--danger: #f87171;
--ring: #a5b4fc;
}
}
[data-theme="dark"] {
color-scheme: dark;
--background: #09090b;
--surface: #131316;
--surface-hover: #1c1c21;
--border: #2a2a30;
--foreground: #f4f4f5;
--muted: #a1a1aa;
--accent: #818cf8;
--accent-hover: #a5b4fc;
--accent-foreground: #0b0b1a;
--danger: #f87171;
--ring: #a5b4fc;
}
@theme inline {
--color-background: var(--background);
--color-surface: var(--surface);
--color-surface-hover: var(--surface-hover);
--color-border: var(--border);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-foreground: var(--accent-foreground);
--color-danger: var(--danger);
--color-ring: var(--ring);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--ease-snappy: cubic-bezier(0.2, 0.8, 0.2, 1);
}
body {
background: var(--background);
color: var(--foreground);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/site/providers";
import { SiteHeader } from "@/components/site/site-header";
import { themeScript } from "@/lib/theme";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: "Design Engineering Playground",
template: "%s · Design Engineering Playground",
},
description: "Interface components built with care for motion, accessibility and design tokens.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
// suppressHydrationWarning: the theme script may set data-theme before React hydrates.
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="flex min-h-full flex-col font-sans">
<Providers>
<SiteHeader />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-12 sm:px-6 sm:py-16">
{children}
</main>
</Providers>
</body>
</html>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { ButtonDemo, SegmentedControlDemo, SwitchDemo } from "@/components/site/demos";
import { Specimen } from "@/components/site/specimen";
export default function Home() {
return (
<div className="flex flex-col gap-12">
<header className="flex max-w-2xl flex-col gap-4">
<p className="font-mono text-xs tracking-wider text-accent uppercase">Design engineering</p>
<h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
Small components with the details sweated.
</h1>
<p className="text-lg text-pretty text-muted">
Each piece here is built on shared design tokens. It works with a keyboard and a screen
reader, and its motion follows your system settings. Try switching the theme or tabbing
through the page.
</p>
</header>
<div className="grid gap-6 md:grid-cols-2">
<Specimen
title="Button"
description="Four variants. While loading it keeps its width, so nothing around it shifts."
notes={["Spring press feedback", "aria-busy while loading", "Visible focus ring"]}
>
<ButtonDemo />
</Specimen>
<Specimen
title="Segmented control"
description="A radio group whose selection indicator glides between options."
notes={["← → Home End", "Roving tabindex", "Shared-layout animation"]}
>
<SegmentedControlDemo />
</Specimen>
<Specimen
title="Switch"
description="Works controlled or uncontrolled. Clicking the label toggles it too."
notes={['role="switch"', "Description wired to aria-describedby", "Spring thumb"]}
>
<SwitchDemo />
</Specimen>
<Specimen
title="Theme"
description="Auto, light and dark, chosen in the header. The choice persists without a flash on reload."
notes={["Inline pre-paint script", "useSyncExternalStore", "Semantic colour tokens"]}
>
<div className="grid w-full max-w-xs grid-cols-3 gap-2" aria-hidden>
{[
"bg-background",
"bg-surface",
"bg-surface-hover",
"bg-accent",
"bg-muted",
"bg-danger",
].map((c) => (
<div key={c} className={`h-12 rounded-lg border border-border ${c}`} />
))}
</div>
</Specimen>
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import { colorTokens, durations, springs } from "@/tokens/tokens";
export const metadata: Metadata = { title: "Tokens" };
export default function TokensPage() {
return (
<div className="flex flex-col gap-14">
<header className="flex max-w-2xl flex-col gap-3">
<h1 className="text-3xl font-semibold tracking-tight">Tokens</h1>
<p className="text-muted">
Components use these semantic names and never raw values. That is why the whole site
changes theme by swapping one set of CSS variables. Toggle the theme to see each swatch
change.
</p>
</header>
<section className="flex flex-col gap-5">
<h2 className="text-lg font-semibold">Colour</h2>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{colorTokens.map((token) => (
<li
key={token.name}
className="flex items-center gap-4 rounded-xl border border-border bg-surface p-3"
>
<span
className="size-12 shrink-0 rounded-lg border border-border"
style={{ background: `var(--${token.name})` }}
/>
<span className="flex min-w-0 flex-col">
<code className="font-mono text-sm">{token.name}</code>
<span className="truncate text-sm text-muted">{token.role}</span>
</span>
</li>
))}
</ul>
</section>
<section className="flex flex-col gap-5">
<h2 className="text-lg font-semibold">Motion</h2>
<div className="grid gap-6 sm:grid-cols-2">
<TokenTable
caption="Durations"
rows={Object.entries(durations).map(([name, s]) => [name, `${s * 1000}ms`])}
/>
<TokenTable
caption="Springs"
rows={Object.entries(springs).map(([name, s]) => [
name,
`stiffness ${s.stiffness} · damping ${s.damping}`,
])}
/>
</div>
</section>
</div>
);
}
function TokenTable({ caption, rows }: { caption: string; rows: [string, string][] }) {
return (
<table className="w-full overflow-hidden rounded-xl border border-border bg-surface text-sm">
<caption className="mb-2 text-left text-sm text-muted">{caption}</caption>
<tbody>
{rows.map(([name, value]) => (
<tr key={name} className="border-t border-border first:border-t-0">
<th scope="row" className="px-4 py-3 text-left font-mono font-normal">
{name}
</th>
<td className="px-4 py-3 text-right font-mono text-muted">{value}</td>
</tr>
))}
</tbody>
</table>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Switch } from "@/components/ui/switch";
export function ButtonDemo() {
const [saving, setSaving] = useState(false);
function save() {
setSaving(true);
setTimeout(() => setSaving(false), 1600);
}
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<Button loading={saving} onClick={save}>
Save changes
</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="ghost">Skip</Button>
<Button variant="danger">Delete</Button>
</div>
);
}
export function SwitchDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<Switch label="Email notifications" description="A summary every Monday." defaultChecked />
<Switch label="Product updates" />
<Switch label="Beta features" description="Managed by your organisation." disabled />
</div>
);
}
const views = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
{ value: "year", label: "Year" },
] as const;
export function SegmentedControlDemo() {
const [view, setView] = useState<(typeof views)[number]["value"]>("week");
return (
<div className="flex flex-col items-center gap-4">
<SegmentedControl
aria-label="Calendar view"
options={views}
value={view}
onValueChange={setView}
/>
<p className="text-sm text-muted" aria-live="polite">
Showing the <span className="font-medium text-foreground">{view}</span> view
</p>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { MotionConfig } from "motion/react";
import type { ReactNode } from "react";
/** Motion follows the OS "reduce motion" setting everywhere. */
export function Providers({ children }: { children: ReactNode }) {
return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { ThemeToggle } from "./theme-toggle";
export function SiteHeader() {
return (
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4 px-4 sm:px-6">
<nav className="flex items-center gap-5 text-sm">
<Link href="/" className="font-semibold">
Playground
</Link>
<Link href="/tokens" className="text-muted transition-colors hover:text-foreground">
Tokens
</Link>
</nav>
<ThemeToggle />
</div>
</header>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
/** A framed showcase for one component: the live demo plus the details behind it. */
export function Specimen({
title,
description,
notes,
children,
}: {
title: string;
description: string;
notes: string[];
children: ReactNode;
}) {
return (
<section className="overflow-hidden rounded-2xl border border-border bg-surface">
<div className="flex min-h-48 items-center justify-center border-b border-border bg-background/60 p-8">
{children}
</div>
<div className="flex flex-col gap-3 p-6">
<div>
<h2 className="text-base font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted">{description}</p>
</div>
<ul className="flex flex-wrap gap-2 text-xs text-muted">
{notes.map((note) => (
<li key={note} className="rounded-full border border-border px-2.5 py-1">
{note}
</li>
))}
</ul>
</div>
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useSyncExternalStore } from "react";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { getTheme, setTheme, subscribeTheme, type ThemePreference } from "@/lib/theme";
const options = [
{ value: "system", label: "Auto" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
] as const satisfies readonly { value: ThemePreference; label: string }[];
export function ThemeToggle() {
// The server renders "system". The client switches to the saved preference after hydration.
const theme = useSyncExternalStore(subscribeTheme, getTheme, () => "system" as const);
return (
<SegmentedControl
aria-label="Colour theme"
size="sm"
options={options}
value={theme}
onValueChange={setTheme}
/>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
describe("Button", () => {
it("defaults to type=button so it never submits a form by accident", () => {
render(<Button>Save</Button>);
expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute("type", "button");
});
it("keeps its accessible name and blocks clicks while loading", async () => {
const onClick = vi.fn();
render(
<Button loading onClick={onClick}>
Save
</Button>,
);
const button = screen.getByRole("button", { name: "Save" });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-busy", "true");
await userEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
});
});
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { motion, type HTMLMotionProps } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/lib/cn";
import { springs } from "@/tokens/tokens";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> {
variant?: ButtonVariant;
size?: ButtonSize;
/** Shows a spinner and blocks clicks. The button keeps its width and its accessible name. */
loading?: boolean;
children?: ReactNode;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: "bg-accent text-accent-foreground hover:bg-accent-hover",
secondary: "border border-border bg-surface text-foreground hover:bg-surface-hover",
ghost: "text-foreground hover:bg-surface-hover",
danger: "bg-danger text-white hover:brightness-110",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-5 text-base",
};
export function Button({
variant = "primary",
size = "md",
loading = false,
disabled,
type = "button",
className,
children,
...props
}: ButtonProps) {
const isDisabled = disabled || loading;
return (
<motion.button
type={type}
disabled={isDisabled}
aria-busy={loading || undefined}
whileTap={isDisabled ? undefined : { scale: 0.97 }}
transition={springs.press}
className={cn(
"relative inline-flex items-center justify-center rounded-lg font-medium select-none",
"transition-[background-color,border-color,filter] duration-150 ease-snappy",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-60",
variantClasses[variant],
sizeClasses[size],
className,
)}
{...props}
>
{/* opacity-0 rather than invisible, so screen readers still hear the label while loading */}
<span className={cn("inline-flex items-center gap-2", loading && "opacity-0")}>
{children}
</span>
{loading && (
<span className="absolute inset-0 grid place-items-center" aria-hidden>
<Spinner />
</span>
)}
</motion.button>
);
}
function Spinner() {
return (
<svg className="size-4 animate-spin" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path
d="M14.5 8A6.5 6.5 0 0 0 8 1.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { SegmentedControl } from "./segmented-control";
const options = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
] as const;
function setup() {
render(<SegmentedControl aria-label="View" options={options} defaultValue="week" />);
return (name: string) => screen.getByRole("radio", { name });
}
describe("SegmentedControl", () => {
it("is a labelled radio group with one Tab stop", () => {
const radio = setup();
expect(screen.getByRole("radiogroup", { name: "View" })).toBeInTheDocument();
expect(radio("Week")).toHaveAttribute("tabindex", "0");
expect(radio("Day")).toHaveAttribute("tabindex", "-1");
});
it("moves selection and focus with arrow keys, wrapping at the ends", async () => {
const radio = setup();
await userEvent.tab();
expect(radio("Week")).toHaveFocus();
await userEvent.keyboard("{ArrowRight}");
expect(radio("Month")).toHaveFocus();
expect(radio("Month")).toHaveAttribute("aria-checked", "true");
await userEvent.keyboard("{ArrowRight}");
expect(radio("Day")).toHaveFocus();
await userEvent.keyboard("{End}");
expect(radio("Month")).toHaveAttribute("aria-checked", "true");
});
});
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { motion } from "motion/react";
import { useId, useRef, type KeyboardEvent } from "react";
import { useControllableState } from "@/hooks/use-controllable-state";
import { cn } from "@/lib/cn";
import { springs } from "@/tokens/tokens";
export interface SegmentedOption<T extends string> {
value: T;
label: string;
}
export interface SegmentedControlProps<T extends string> {
options: readonly SegmentedOption<T>[];
/** Required: a segmented control has no visible group label. */
"aria-label": string;
value?: T;
defaultValue?: T;
onValueChange?: (value: T) => void;
size?: "sm" | "md";
className?: string;
}
/**
* A single-choice control (WAI-ARIA radio group).
* Arrow keys move and select, Home/End jump, and only the selected option is a
* Tab stop. The selection indicator is one shared element that glides between
* options.
*/
export function SegmentedControl<T extends string>({
options,
value,
defaultValue,
onValueChange,
size = "md",
className,
"aria-label": ariaLabel,
}: SegmentedControlProps<T>) {
const [selected, setSelected] = useControllableState<T>({
value,
defaultValue: defaultValue ?? options[0].value,
onChange: onValueChange,
});
const indicatorId = useId();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
function onKeyDown(event: KeyboardEvent, index: number) {
const last = options.length - 1;
let next: number;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = index === last ? 0 : index + 1;
break;
case "ArrowLeft":
case "ArrowUp":
next = index === 0 ? last : index - 1;
break;
case "Home":
next = 0;
break;
case "End":
next = last;
break;
default:
return;
}
event.preventDefault();
setSelected(options[next].value);
refs.current[next]?.focus();
}
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn(
"inline-flex rounded-lg border border-border bg-surface-hover p-0.5",
className,
)}
>
{options.map((option, index) => {
const isSelected = option.value === selected;
return (
<button
key={option.value}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={isSelected}
tabIndex={isSelected ? 0 : -1}
onClick={() => setSelected(option.value)}
onKeyDown={(e) => onKeyDown(e, index)}
className={cn(
"relative rounded-md font-medium transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
size === "sm" ? "h-7 px-2.5 text-xs" : "h-8 px-3.5 text-sm",
isSelected ? "text-foreground" : "text-muted hover:text-foreground",
)}
>
{isSelected && (
<motion.span
layoutId={indicatorId}
aria-hidden
className="absolute inset-0 rounded-md bg-surface shadow-sm ring-1 ring-border"
transition={springs.snappy}
/>
)}
<span className="relative">{option.label}</span>
</button>
);
})}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Switch } from "./switch";
describe("Switch", () => {
it("toggles when the label is clicked and reports the change", async () => {
const onChange = vi.fn();
render(<Switch label="Notifications" onCheckedChange={onChange} />);
const toggle = screen.getByRole("switch", { name: "Notifications" });
expect(toggle).toHaveAttribute("aria-checked", "false");
await userEvent.click(screen.getByText("Notifications"));
expect(toggle).toHaveAttribute("aria-checked", "true");
expect(onChange).toHaveBeenCalledWith(true);
});
it("toggles with Space from the keyboard", async () => {
render(<Switch label="Beta" />);
await userEvent.tab();
await userEvent.keyboard(" ");
expect(screen.getByRole("switch", { name: "Beta" })).toHaveAttribute("aria-checked", "true");
});
it("stays put when controlled and the parent does not update", async () => {
render(<Switch label="Locked" checked={false} onCheckedChange={() => {}} />);
await userEvent.click(screen.getByRole("switch"));
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false");
});
it("links the description for screen readers", () => {
render(<Switch label="Email" description="Weekly summary" />);
expect(screen.getByRole("switch", { name: "Email" })).toHaveAccessibleDescription(
"Weekly summary",
);
});
});
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { motion } from "motion/react";
import { useId } from "react";
import { useControllableState } from "@/hooks/use-controllable-state";
import { cn } from "@/lib/cn";
import { springs } from "@/tokens/tokens";
export interface SwitchProps {
label: string;
description?: string;
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
export function Switch({
label,
description,
checked,
defaultChecked = false,
onCheckedChange,
disabled,
className,
}: SwitchProps) {
const [isOn, setOn] = useControllableState({
value: checked,
defaultValue: defaultChecked,
onChange: onCheckedChange,
});
const id = useId();
const descriptionId = `${id}-description`;
return (
<div className={cn("flex items-center justify-between gap-6", className)}>
<div className="flex flex-col gap-0.5">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
{description && (
<span id={descriptionId} className="text-sm text-muted">
{description}
</span>
)}
</div>
<button
id={id}
type="button"
role="switch"
aria-checked={isOn}
aria-describedby={description ? descriptionId : undefined}
disabled={disabled}
onClick={() => setOn(!isOn)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5",
"transition-colors duration-200 ease-snappy",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
isOn ? "bg-accent" : "bg-border",
)}
>
<motion.span
aria-hidden
className="size-5 rounded-full bg-white shadow-sm"
initial={false}
animate={{ x: isOn ? 20 : 0 }}
transition={springs.snappy}
/>
</button>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useCallback, useState } from "react";
/**
* Lets a component work controlled (`value` + `onChange`) or uncontrolled
* (`defaultValue`) with one code path, as native inputs do.
*/
export function useControllableState<T>({
value,
defaultValue,
onChange,
}: {
value?: T;
defaultValue: T;
onChange?: (value: T) => void;
}) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = value !== undefined;
const current = isControlled ? value : internal;
const setValue = useCallback(
(next: T) => {
if (!isControlled) setInternal(next);
onChange?.(next);
},
[isControlled, onChange],
);
return [current, setValue] as const;
}
+5
View File
@@ -0,0 +1,5 @@
import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
+39
View File
@@ -0,0 +1,39 @@
export type ThemePreference = "system" | "light" | "dark";
const STORAGE_KEY = "theme";
const listeners = new Set<() => void>();
/**
* Runs in <head> before first paint so a saved theme never flashes.
* "system" is the absence of data-theme, which leaves it to prefers-color-scheme.
*/
export const themeScript = `(function(){try{var t=localStorage.getItem("${STORAGE_KEY}");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}})()`;
export function getTheme(): ThemePreference {
try {
const value = localStorage.getItem(STORAGE_KEY);
return value === "light" || value === "dark" ? value : "system";
} catch {
return "system";
}
}
export function setTheme(theme: ThemePreference) {
try {
if (theme === "system") localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, theme);
} catch {
// Storage can be unavailable (private mode). The theme still applies for this visit.
}
const root = document.documentElement;
if (theme === "system") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", theme);
listeners.forEach((listener) => listener());
}
export function subscribeTheme(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
+46
View File
@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { colorTokens } from "./tokens";
const css = readFileSync(join(__dirname, "../app/globals.css"), "utf8");
/** Returns the body of the first CSS block whose selector contains `selector`. */
function block(selector: string): string {
const start = css.indexOf(selector);
if (start === -1) throw new Error(`Selector not found: ${selector}`);
const open = css.indexOf("{", start);
const close = css.indexOf("}", open);
return css.slice(open + 1, close);
}
describe("design tokens", () => {
const themes = {
light: block('[data-theme="light"]'),
"dark (system)": block(':root:not([data-theme="light"])'),
"dark (explicit)": block('[data-theme="dark"] {'),
};
for (const [theme, body] of Object.entries(themes)) {
it.each(colorTokens.map((t) => t.name))(`defines --%s in ${theme}`, (name) => {
expect(body).toMatch(new RegExp(`--${name}:\\s*[^;]+;`));
});
}
it("exposes every token to Tailwind", () => {
const theme = block("@theme inline");
for (const { name } of colorTokens) {
expect(theme).toContain(`--color-${name}: var(--${name});`);
}
});
it("keeps system dark and explicit dark in sync", () => {
const norm = (s: string) =>
s
.split(";")
.map((l) => l.trim())
.filter(Boolean)
.sort();
expect(norm(themes["dark (system)"])).toEqual(norm(themes["dark (explicit)"]));
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { Transition } from "motion/react";
/**
* Metadata for the colour tokens defined in src/app/globals.css.
* The CSS is the source of the values. This file is the source of names
* and intent, and it powers the /tokens page.
*/
export const colorTokens = [
{ name: "background", role: "Page canvas" },
{ name: "surface", role: "Cards, inputs, raised elements" },
{ name: "surface-hover", role: "Hover state for surfaces and ghost buttons" },
{ name: "border", role: "Hairlines and dividers" },
{ name: "foreground", role: "Primary text and icons" },
{ name: "muted", role: "Secondary text, captions" },
{ name: "accent", role: "Primary actions, selected states" },
{ name: "accent-hover", role: "Hover state for accent" },
{ name: "accent-foreground", role: "Text on accent" },
{ name: "danger", role: "Destructive actions, errors" },
{ name: "ring", role: "Focus indicator" },
] as const;
export type ColorToken = (typeof colorTokens)[number]["name"];
/** Durations in seconds (Motion's unit). */
export const durations = {
instant: 0.08,
fast: 0.15,
base: 0.24,
slow: 0.4,
} as const;
/** Named motion presets, so interactions feel like one system. */
export const springs = {
/** Toggles, indicators: quick and settled, no visible overshoot. */
snappy: { type: "spring", stiffness: 520, damping: 34, mass: 0.8 },
/** Press feedback: very short and firm. */
press: { type: "spring", stiffness: 800, damping: 40 },
/** Larger surfaces entering: softer. */
gentle: { type: "spring", stiffness: 260, damping: 30 },
} as const satisfies Record<string, Transition>;