feat(design): design lab + DESIGN.md skeleton (E28 S2a)

- docs/DESIGN.md: the written source of truth for the UI. Decided chapters
  (principles, device adaptation, action placement, text stays selectable,
  interaction rules, copy, anti-patterns, decision log); controls and
  surfaces are marked Pending until the owner picks from the lab.
- /design-lab (dev only): renders each open decision's variants with the
  app's real CSS, themes and font scale. Batch A = six control decisions.
- devOnlyGuard: a production build fails if anything under src/design-lab
  reaches the bundle (seen red with an unguarded import).
- Size contract: tablet portrait + landscape and up, replacing the
  1024x768 floor with its never-built "too small" message.
This commit is contained in:
2026-09-24 01:34:12 +02:00
parent 58878958d8
commit d2e40dcffb
14 changed files with 1087 additions and 10 deletions
@@ -127,7 +127,7 @@ describe("Button primitive (S5)", () => {
});
it("carries the coarse-pointer minimum target, except on a link", () => {
// The 1024x768 comfort contract: the minimum lives in the primitive, so no call site can opt
// The tablet comfort contract: the minimum lives in the primitive, so no call site can opt
// out of it by accident. A link is text in a sentence and is excluded by design.
const { unmount } = render(<Button>Tap</Button>);
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
+1 -1
View File
@@ -155,7 +155,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
// here rather than by asking every call site to pass `size="none"` alongside `variant="link"` —
// a pairing everyone would eventually forget, which is how a "link" grows a button's box.
const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md");
// The 1024x768 comfort contract lives HERE, not in the call sites: on a coarse pointer every
// The tablet comfort contract (docs/DESIGN.md) lives HERE, not in the call sites: on a coarse pointer every
// button-shaped control gets at least a 2.75rem box (see .touch-target in index.css). `link` is
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
// wreck the paragraph it sits in rather than make anything easier to hit.
+4 -4
View File
@@ -46,10 +46,10 @@ domain-free.
## The size contract (E10 S1c)
The app optimises for **1024x768 (tablet landscape) as the floor**; below it we owe an honest
message, above it correct rendering all the way up. That contract lives in these components, not in
the ~160 call sites, because a rule repeated in 160 places is a rule that is already broken
somewhere:
The app supports **tablet portrait and landscape and every size above them** (from 768px wide; phones
are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Device adaptation). Measure a UI
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` are the rungs, derived from what
the app actually used. Pick a rung; do not hand-roll padding on a primitive.
+126
View File
@@ -0,0 +1,126 @@
import { useLayoutEffect, useState } from "react";
import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
import { A1, A2, A3, A4, A5, A6 } from "./batchA";
const BATCH_A = [
{ id: "a1", code: "A1", title: "Button shape", render: A1 },
{ id: "a2", code: "A2", title: "Icon buttons", render: A2 },
{ id: "a3", code: "A3", title: "Touch size", render: A3 },
{ id: "a4", code: "A4", title: "Input", render: A4 },
{ id: "a5", code: "A5", title: "Dropdown trigger", render: A5 },
{ id: "a6", code: "A6", title: "Checkbox / switch / slider", render: A6 },
] as const;
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
// OUTSIDE <App>, so it owns the theme attributes on <html> itself, through the same data-theme /
// data-scheme / --font-scale / data-backdrop switches the app's PrefsProvider drives. Nothing here
// is saved: the lab's bar changes this tab only.
type Look = { mode: "dark" | "light"; scheme: Scheme; fontScale: number; backdrop: boolean };
// The owner's own daily look (E28 S1 inventory) is the starting point — decisions are taken on it.
const START: Look = { mode: "dark", scheme: "midnight", fontScale: 1.02, backdrop: true };
function applyLook(l: Look) {
const el = document.documentElement;
el.dataset.theme = l.mode;
el.dataset.scheme = l.scheme;
el.dataset.backdrop = l.backdrop ? "on" : "off";
el.style.setProperty("--font-scale", String(l.fontScale));
}
const chip = (on: boolean) =>
clsx(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition",
on ? "border-accent bg-accent text-accent-fg" : "border-border text-muted hover:text-fg",
);
export default function DesignLab() {
const [look, setLook] = useState<Look>(START);
useLayoutEffect(() => {
applyLook(START);
document.title = "Design lab · Siftlode";
}, []);
const set = (patch: Partial<Look>) => {
const next = { ...look, ...patch };
applyLook(next);
setLook(next);
};
return (
<div className="min-h-dvh text-fg" data-testid="design-lab">
<div className="glass sticky top-0 z-chrome flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2.5">
<strong className="text-sm">Design lab</strong>
<span className="flex gap-1">
{(["dark", "light"] as const).map((m) => (
<button
key={m}
type="button"
className={chip(look.mode === m)}
onClick={() => set({ mode: m })}
>
{m}
</button>
))}
</span>
<span className="flex flex-wrap gap-1">
{SCHEMES.map((s) => (
<button
key={s.id}
type="button"
className={chip(look.scheme === s.id)}
onClick={() => set({ scheme: s.id })}
>
<span className="h-2.5 w-2.5 rounded-full" style={{ background: s.swatch }} />
{s.name}
</button>
))}
</span>
<button
type="button"
className={chip(look.backdrop)}
onClick={() => set({ backdrop: !look.backdrop })}
>
backdrop
</button>
<label className="flex items-center gap-2 text-xs text-muted">
text {look.fontScale.toFixed(2)}
<input
type="range"
min={0.9}
max={1.2}
step={0.02}
value={look.fontScale}
onChange={(e) => set({ fontScale: Number(e.target.value) })}
className="w-24 accent-accent"
/>
</label>
<nav className="flex flex-wrap gap-1 text-xs">
{BATCH_A.map((d) => (
<a
key={d.id}
href={`#${d.id}`}
className="rounded-md px-1.5 py-0.5 text-muted hover:bg-card hover:text-fg"
>
{d.code} {d.title}
</a>
))}
</nav>
</div>
<main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-4">
<header>
<h1 className="text-2xl font-bold">Batch A · controls</h1>
<p className="text-sm text-muted">
Each decision shows its variants with the app&apos;s real CSS. The framed one is the
recommendation. Resize the window to tablet size to see them there too.
</p>
</header>
{BATCH_A.map((d) => (
<d.render key={d.id} />
))}
</main>
</div>
);
}
+572
View File
@@ -0,0 +1,572 @@
import clsx from "clsx";
import type { CSSProperties, ReactNode } from "react";
import {
Check,
ChevronDown,
Download,
ListPlus,
MoreHorizontal,
Pencil,
Play,
RefreshCw,
Search,
Trash2,
} from "lucide-react";
import { Contrast, Decision, Sample, Variant } from "./labKit";
// E28 S2 · Batch A — controls. Each decision renders its variants with the app's real CSS classes
// (glass, btn-primary, tokens); nothing here is wired to data. The markup is the proposal itself.
// ── shared proposal pieces ─────────────────────────────────────────────────────────────────────
type Tone = "primary" | "ghost" | "outline" | "glass" | "danger";
type Size = "xs" | "sm" | "md";
type Radius = "rounded-lg" | "rounded-xl" | "rounded-full";
const TONE: Record<Tone, string> = {
primary: "font-medium btn-primary",
ghost: "font-medium text-muted hover:text-fg hover:bg-card",
outline: "font-medium border border-border text-muted hover:text-accent hover:border-accent",
glass: "font-medium glass-card glass-hover hover:text-accent",
danger: "font-medium btn-danger",
};
const SIZE: Record<Size, string> = {
xs: "px-2 py-1 text-xs gap-1",
sm: "px-3 py-1.5 text-sm gap-1.5",
md: "px-3 py-2 text-sm gap-1.5",
};
function Btn({
tone,
size,
radius,
icon,
children,
}: {
tone: Tone;
size: Size;
radius: Radius;
icon?: ReactNode;
children: ReactNode;
}) {
return (
<button
type="button"
className={clsx(
"inline-flex items-center justify-center transition",
TONE[tone],
SIZE[size],
radius,
)}
>
{icon}
{children}
</button>
);
}
const ico = "w-4 h-4";
// ── A1 · button shape and ladder ──────────────────────────────────────────────────────────────────
function ButtonSet({ radius }: { radius: Radius }) {
return (
<>
<Sample label="Toolbar / panel header · sm">
<div className="flex flex-wrap gap-2">
<Btn tone="glass" size="sm" radius={radius} icon={<RefreshCw className={ico} />}>
Sync now
</Btn>
<Btn tone="outline" size="sm" radius={radius}>
Full history
</Btn>
</div>
</Sample>
<Sample label="Dialog footer / form · md">
<div className="flex flex-wrap justify-end gap-2">
<Btn tone="ghost" size="md" radius={radius}>
Cancel
</Btn>
<Btn tone="danger" size="md" radius={radius} icon={<Trash2 className={ico} />}>
Delete
</Btn>
<Btn tone="primary" size="md" radius={radius}>
Save
</Btn>
</div>
</Sample>
<Sample label="Inside a table row · xs">
<div className="flex items-center justify-between rounded-lg bg-card/60 px-3 py-2 text-sm">
<span className="truncate">Download failed · HTTP 403</span>
<Btn tone="outline" size="xs" radius={radius}>
Retry
</Btn>
</div>
</Sample>
</>
);
}
export function A1() {
return (
<Decision
id="a1"
code="A1"
title="Button shape and ladder"
question="Which corner radius do action buttons have, and which size goes where?"
now="69 button variants. Through Button: r8, but the glass buttons built directly on FocusSafeButton are r12 (6 modules), following an older glass-phase house note (glass buttons = rounded-xl). Ten heights in use; dialog footers h32, forms h38."
>
<Variant
letter="A"
name="r8 (rounded-lg) + a size rule per context"
recommended
why="The inner corner is smaller than the outer one: an r8 button inside an r12 card or an r16 dialog. It matches the locked tabs = rounded-lg. Glass becomes a Button variant, so the r12 FocusSafeButton buttons go. Sizes: md = forms and dialog footers, sm = toolbars and panel headers, xs = inside a table row."
>
<ButtonSet radius="rounded-lg" />
</Variant>
<Variant
letter="B"
name="r12 (rounded-xl)"
why="Softer, but the button now has the same radius as the card it sits in, so it reads as a card inside a card. On a 26px xs button r12 is almost a pill."
>
<ButtonSet radius="rounded-xl" />
</Variant>
<Variant
letter="C"
name="Pill (rounded-full)"
why="The same shape as chips, segmented toggles and dropdown triggers. In this app the pill means 'a filter or a choice', so actions and filters would look alike."
>
<ButtonSet radius="rounded-full" />
</Variant>
</Decision>
);
}
// ── A2 · icon buttons ─────────────────────────────────────────────────────────────────────────────
function Icon({ box, radius, children }: { box: string; radius: string; children: ReactNode }) {
return (
<button
type="button"
className={clsx(
"grid place-items-center text-muted hover:text-fg hover:bg-card transition",
box,
radius,
)}
>
{children}
</button>
);
}
function OnMedia({ radius }: { radius: string }) {
const b = clsx(
"grid h-8 w-8 place-items-center bg-black/55 text-white hover:bg-black/75",
radius,
);
return (
<div className="relative aspect-video overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
<span className="absolute bottom-1.5 right-1.5 flex gap-1.5">
<button type="button" className={b}>
<Play className={ico} />
</button>
<button type="button" className={b}>
<ListPlus className={ico} />
</button>
</span>
</div>
);
}
function IconSet({ row, bar, media }: { row: string; bar: string; media: string }) {
const r = row.includes("full") ? "rounded-full" : "rounded-lg";
return (
<>
<Sample label="Table-row actions">
<div className="flex items-center justify-between rounded-lg bg-card/60 px-3 py-1 text-sm">
<span className="truncate">Techno Tim</span>
<span className="flex gap-1">
<Icon box={row} radius={r}>
<Pencil className={ico} />
</Icon>
<Icon box={row} radius={r}>
<RefreshCw className={ico} />
</Icon>
<Icon box={row} radius={r}>
<Trash2 className={ico} />
</Icon>
</span>
</div>
</Sample>
<Sample label="Toolbar, on its own">
<div className="flex gap-2">
<Icon box={bar} radius={r}>
<Download className={ico} />
</Icon>
<Icon box={bar} radius={r}>
<MoreHorizontal className={ico} />
</Icon>
</div>
</Sample>
<Sample label="On a picture (on-media)">
<OnMedia radius={media} />
</Sample>
</>
);
}
export function A2() {
return (
<Decision
id="a2"
code="A2"
title="Icon buttons"
question="How many icon-button sizes are there, and what does one look like on a picture?"
now="44 variants, 33 of them hand-rolled. Heights 10–38, six radii. The video-card trio is hand-rolled at 24 and 28."
>
<Variant
letter="A"
name="Two sizes (32 / 36), r8, plus a round on-media button"
recommended
why="32 in rows and dense toolbars, 36 on its own. The only round one sits on a picture: a dark translucent disc stays readable on any thumbnail. The two existing Button sizes (icon-sm / icon) become the only ones."
>
<IconSet row="w-8 h-8" bar="w-9 h-9" media="rounded-full" />
</Variant>
<Variant
letter="B"
name="One size (32) everywhere, square on the picture too"
why="The fewest decisions, but a lone toolbar icon at 32 looks lost next to 38px buttons, and a square on-media button looks like a UI panel pasted on the image."
>
<IconSet row="w-8 h-8" bar="w-8 h-8" media="rounded-lg" />
</Variant>
<Variant
letter="C"
name="Every icon button round"
why="Uniform, but round in the app means a chip or a player control. Rows of round buttons read as tags, not actions."
>
<IconSet row="w-8 h-8 rounded-full" bar="w-9 h-9 rounded-full" media="rounded-full" />
</Variant>
</Decision>
);
}
// ── A3 · the touch rule beyond Button ────────────────────────────────────────────────────────────
function TouchRow({ h, gap }: { h: string; gap: string }) {
const pill = clsx("inline-flex items-center px-3 text-sm", h);
return (
<div className={clsx("flex flex-col", gap)}>
<Sample label="Segmented toggle · dropdown trigger">
<div className={clsx("flex flex-wrap items-center", gap)}>
<span className="inline-flex rounded-full border border-border bg-card p-0.5 text-xs">
<span className={clsx(pill, "rounded-full bg-accent text-accent-fg")}>Mine</span>
<span className={clsx(pill, "rounded-full text-muted")}>Library</span>
</span>
<span className={clsx(pill, "glass-card rounded-full border gap-1.5")}>
<span className="text-muted">Sort:</span> Newest
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</span>
</div>
</Sample>
<Sample label="Tabs">
<div className={clsx("flex", gap)}>
<span className={clsx(pill, "rounded-lg bg-accent text-accent-fg")}>Queue</span>
<span className={clsx(pill, "rounded-lg text-muted")}>Library</span>
</div>
</Sample>
<Sample label="Switch row · nav item">
<div className={clsx("flex items-center justify-between rounded-lg bg-card/60 px-3", h)}>
<span className="text-sm">Dark mode</span>
<span className="relative h-5 w-9 rounded-full bg-accent">
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-white" />
</span>
</div>
<div
className={clsx("flex items-center gap-2 rounded-lg bg-accent/15 px-3 text-accent", h)}
>
<Play className={ico} /> <span className="text-sm">Feed</span>
</div>
</Sample>
</div>
);
}
export function A3() {
return (
<Decision
id="a3"
code="A3"
title="The touch size beyond Button"
question="On a tablet (a coarse pointer), how big do the segmented toggles, dropdown triggers, tabs, switch rows and nav items become? These are shown in their TABLET size; the mouse sizes stay as they are."
now="Only ui/Button grows on touch (to 44). On a tablet the segmented toggle is 24, the trigger 30, the tabs 32, the switch 36×20, the nav item 38×34."
>
<Variant
letter="A"
name="44 (2.75rem): the same rule as Button"
recommended
why="HIG 44pt, and the rule Button already follows, extended to every control primitive through one token. A tablet toolbar grows by about 14px; mouse sizes don't change."
>
<TouchRow h="min-h-11" gap="gap-2" />
</Variant>
<Variant
letter="B"
name="36 (2.25rem) + 8px gaps"
why="Meets WCAG 2.5.8 (24 + spacing) comfortably and keeps the toolbar denser, but is below HIG. Two different touch sizes (Button 44, the rest 36) is a rule nobody remembers."
>
<TouchRow h="min-h-9" gap="gap-2" />
</Variant>
<Variant
letter="—"
name="Today, on a tablet (reference)"
why="What the S1c measurement found at a coarse pointer: 24 / 30 / 32 / 20 / 34."
>
<TouchRow h="min-h-6" gap="gap-1" />
</Variant>
</Decision>
);
}
// ── A4 · the input ───────────────────────────────────────────────────────────────────────────────
// The proposed stronger border: the text colour mixed into the page background, opaque, so its
// contrast is the same on glass and on flat surfaces. Measured over all 12 scheme × mode pairs:
// 45% fails (light/forest 2.68), 50% scrapes by (3.06), 55% clears 3:1 with margin (worst 3.52).
const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
function Field({
cls,
style,
searchCls,
searchStyle,
}: {
cls: string;
style?: CSSProperties;
searchCls: string;
searchStyle?: CSSProperties;
}) {
return (
<>
<Sample label="Toolbar search">
<label className={clsx("flex items-center gap-2 px-3", searchCls)} style={searchStyle}>
<Search className="h-4 w-4 text-muted" />
<input
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
placeholder="Search your subscriptions"
/>
</label>
</Sample>
<Sample label="Form field, with a label and an error">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">Playlist name</span>
<input
className={clsx("px-3 text-sm outline-none focus:ring-2 focus:ring-accent", cls)}
style={style}
defaultValue="Evening mix"
/>
<span className="text-xs text-danger">A playlist with this name already exists.</span>
</label>
</Sample>
</>
);
}
export function A4() {
return (
<Decision
id="a4"
code="A4"
title="The input"
question="What does a text input look like? One primitive in two sizes: a toolbar pill and a form field."
now="There is no input primitive: 10 + 10 variants, heights 20–38, five radii. The border is --border, 1.2–1.6:1 against the page (WCAG 1.4.11 asks 3:1 for a control's edge). Every input is 14px, and the pager's are 12."
>
<Variant
letter="A"
name="Bordered, with a stronger border token"
recommended
why="The edge clears 3:1 in every scheme, so the field is recognisable without a hover. Form: r8, the height of an md button. Toolbar: a pill, like the other toolbar controls. The font is 1rem on a coarse pointer, so a tablet browser doesn't zoom in when the field gets focus."
>
<Field
cls="h-9.5 rounded-lg border bg-transparent"
style={{ borderColor: STRONG }}
searchCls="h-9 rounded-full border"
searchStyle={{ borderColor: STRONG }}
/>
<Contrast fg={STRONG} min={3} label="Edge vs page" />
</Variant>
<Variant
letter="B"
name="Filled, no border (accent underline on focus)"
why="Calm, material-style. But the fill has to be the thing that clears 3:1, and --surface over --bg is far from it, so the fill would need a new, much stronger token that looks heavy on glass."
>
<Field
cls="h-9.5 rounded-lg bg-surface border-b-2 border-transparent focus:border-accent"
searchCls="h-9 rounded-full bg-surface"
/>
<Contrast fg="var(--surface)" min={3} label="Fill vs page" />
</Variant>
<Variant
letter="C"
name="Today's look, made the primitive"
why="No visual change, only the drift goes. The edge stays under 3:1."
>
<Field
cls="h-9.5 rounded-xl glass-card border border-border"
searchCls="h-9 rounded-full glass-card border border-border"
/>
<Contrast fg="var(--border)" min={3} label="Edge vs page" />
</Variant>
</Decision>
);
}
// ── A5 · dropdown trigger ────────────────────────────────────────────────────────────────────────
function Trigger({ cls, label, value }: { cls: string; label: string; value: string }) {
return (
<span
className={clsx(
"glass-card glass-hover inline-flex items-center gap-1.5 border text-sm",
cls,
)}
>
<span className="text-muted">{label}:</span> {value}
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</span>
);
}
function DropdownSet({ bar, form }: { bar: string; form: string }) {
return (
<>
<Sample label="In a toolbar">
<div className="flex flex-wrap gap-2">
<Trigger cls={bar} label="Watch state" value="Unwatched" />
<Trigger cls={bar} label="Date" value="Any time" />
<Trigger cls={bar} label="Sort" value="Newest" />
</div>
</Sample>
<Sample label="In a form">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">Download format</span>
<span
className={clsx("glass-card flex items-center justify-between border text-sm", form)}
>
MP4 · 1080p
<ChevronDown className="h-3.5 w-3.5 text-muted" />
</span>
</label>
</Sample>
</>
);
}
export function A5() {
return (
<Decision
id="a5"
code="A5"
title="The dropdown trigger"
question="What shape is a dropdown trigger?"
now="Two shapes side by side in the same feed toolbar: FilterSelect / Sort / View are pills (h30), the date filter, channel stats, DataTable filter and Plex collections are r8 (h34)."
>
<Variant
letter="A"
name="By context: a pill in a toolbar, r8 in a form"
recommended
why="A toolbar holds filters and choices, and the pill already means that here (chips, segmented). A form field matches the input next to it (A4), so a form reads as one column of the same boxes."
>
<DropdownSet bar="rounded-full px-3 py-1" form="h-9.5 rounded-lg px-3" />
</Variant>
<Variant
letter="B"
name="A pill everywhere"
why="One shape, but in a form a pill select next to r8 inputs breaks the column."
>
<DropdownSet bar="rounded-full px-3 py-1" form="h-9.5 rounded-full px-3" />
</Variant>
<Variant
letter="C"
name="r8 everywhere"
why="One shape, but the toolbar filters then look like the action buttons next to them."
>
<DropdownSet bar="rounded-lg px-3 py-1.5" form="h-9.5 rounded-lg px-3" />
</Variant>
</Decision>
);
}
// ── A6 · checkbox, switch, slider ────────────────────────────────────────────────────────────────
function NativeSet() {
return (
<>
<label className="flex min-h-11 items-center gap-3 rounded-lg px-2 text-sm hover:bg-card">
<input type="checkbox" defaultChecked className="h-4.5 w-4.5 accent-accent" />
Only unwatched
</label>
<label className="flex min-h-11 items-center justify-between gap-3 rounded-lg px-2 text-sm hover:bg-card">
Hide Shorts
<span className="relative h-5 w-9 rounded-full bg-accent">
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-white" />
</span>
</label>
<label className="flex flex-col gap-1 px-2 text-sm">
Text size
<input
type="range"
min={90}
max={120}
defaultValue={102}
className="w-full accent-accent"
/>
</label>
</>
);
}
function CustomSet() {
return (
<>
<div className="flex min-h-11 items-center gap-3 px-2 text-sm">
<span className="grid h-5 w-5 place-items-center rounded-md bg-accent text-accent-fg">
<Check className="h-3.5 w-3.5" />
</span>
Only unwatched
</div>
<div className="flex min-h-11 items-center justify-between gap-3 px-2 text-sm">
Hide Shorts
<span className="relative h-6 w-11 rounded-full bg-accent shadow-inner">
<span className="absolute right-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow" />
</span>
</div>
<div className="flex flex-col gap-2 px-2 text-sm">
Text size
<span className="relative h-1.5 rounded-full bg-border">
<span className="absolute inset-y-0 left-0 w-2/5 rounded-full bg-accent" />
<span className="absolute left-2/5 top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow" />
</span>
</div>
</>
);
}
export function A6() {
return (
<Decision
id="a6"
code="A6"
title="Checkbox, switch, slider"
question="Do these stay the browser's own controls (tinted), or get drawn primitives?"
now="Native checkbox in Playlists (13×13), native range in Appearance and Notifications (two looks, h16), ui/Switch 36×20."
>
<Variant
letter="A"
name="Native, tinted with the accent; the whole row is the target"
recommended
why="Keyboard, screen reader and high-contrast mode come free from the browser. The row label is the touch target (44 tall), so the small box itself does not have to grow. Only 3 sites today, so it is cheap to keep this way."
>
<NativeSet />
</Variant>
<Variant
letter="B"
name="Drawn primitives (Checkbox / Slider)"
why="Identical in every browser and fully on-brand, but every one of those free behaviours has to be rebuilt and tested by hand. That cost is worth it at 30 sites, not at 3."
>
<CustomSet />
</Variant>
</Decision>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { useSyncExternalStore, type ReactNode } from "react";
import clsx from "clsx";
// Building blocks of the design lab. A lab page asks the owner ONE question per <Decision>: two or
// three <Variant>s rendered side by side with the app's real CSS, the recommended one marked and its
// one-line reason (the rule it rests on) printed under it. Everything is static markup on purpose —
// what is being decided is the look, and a variant that only exists here must not need app state.
export function Decision({
id,
code,
title,
question,
now,
children,
}: {
id: string;
code: string;
title: string;
question: string;
/** What the app does today, measured (the E28 S1 inventory). */
now: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className="glass rounded-2xl p-5 scroll-mt-20">
<header className="mb-4">
<div className="flex items-baseline gap-3">
<span className="text-xs font-bold tracking-widest text-accent">{code}</span>
<h2 className="text-lg font-bold">{title}</h2>
</div>
<p className="mt-1 text-sm">{question}</p>
<p className="mt-1 text-xs text-muted">
<span className="font-semibold">Today: </span>
{now}
</p>
</header>
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">{children}</div>
</section>
);
}
export function Variant({
letter,
name,
recommended = false,
why,
children,
}: {
letter: string;
name: string;
recommended?: boolean;
why: string;
children: ReactNode;
}) {
return (
<article
className={clsx(
"glass-card rounded-xl p-4 flex flex-col gap-3 border",
recommended ? "border-accent" : "border-transparent",
)}
>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-full bg-card text-xs font-bold">
{letter}
</span>
<h3 className="text-sm font-semibold flex-1">{name}</h3>
{recommended && (
<span className="rounded-full bg-accent px-2 py-0.5 text-2xs font-bold text-accent-fg">
Recommended
</span>
)}
</div>
<div className="flex flex-col gap-3">{children}</div>
<p className="mt-auto border-t border-border/60 pt-2 text-xs text-muted">{why}</p>
</article>
);
}
/** A small caption above a sample inside a variant. */
export function Sample({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<span className="text-2xs uppercase tracking-wider text-muted">{label}</span>
{children}
</div>
);
}
// ── Measured contrast ───────────────────────────────────────────────────────────────────────────
// A variant that changes a colour prints its contrast against the page background, computed from the
// live CSS — so switching the theme or the scheme in the lab's bar re-measures it. The theme lives on
// <html>'s attributes, so that is what the store subscribes to.
function subscribeTheme(onChange: () => void) {
const mo = new MutationObserver(onChange);
mo.observe(document.documentElement, { attributes: true });
return () => mo.disconnect();
}
let probe: HTMLSpanElement | null = null;
/** Resolve any CSS colour expression (vars, color-mix) to [r, g, b, a] in 0..1 via a hidden probe. */
function resolve(expr: string): [number, number, number, number] {
if (!probe) {
probe = document.createElement("span");
probe.style.display = "none";
document.body.append(probe);
}
probe.style.color = "";
probe.style.color = expr;
const c = getComputedStyle(probe).color;
const [r = 0, g = 0, b = 0, a = 1] = (c.match(/[\d.]+/g) ?? []).map(Number);
// `rgb()` is 0..255; a color-mix() result computes to `color(srgb …)`, already 0..1.
return c.startsWith("color(") ? [r, g, b, a] : [r / 255, g / 255, b / 255, a];
}
const lin = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
const lum = (r: number, g: number, b: number) =>
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
function ratio(fg: string, bg: string): number {
const [br, bg_, bb] = resolve(bg);
const [fr, fg_, fb, fa] = resolve(fg);
// Composite a translucent foreground over the background, as the eye sees it.
const over = (f: number, b: number) => f * fa + b * (1 - fa);
const l1 = lum(over(fr, br), over(fg_, bg_), over(fb, bb));
const l2 = lum(br, bg_, bb);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
/** "3.42:1 ✓" against `min` — re-measured whenever the lab switches theme or scheme. */
export function Contrast({
fg,
bg = "var(--bg)",
min,
label,
}: {
fg: string;
bg?: string;
min: number;
label: string;
}) {
const value = useSyncExternalStore(subscribeTheme, () => ratio(fg, bg).toFixed(2));
const ok = Number(value) >= min;
return (
<span className={clsx("text-2xs tabular-nums", ok ? "text-success" : "text-danger")}>
{label}: {value}:1 {ok ? "✓" : `✗ (needs ${min}:1)`}
</span>
);
}
+1 -1
View File
@@ -583,7 +583,7 @@ body {
opacity: 0.9;
}
/* ===== E10 S1c · the minimum-target half of the 1024x768 comfort contract =====
/* ===== E10 S1c · the minimum-target half of the tablet comfort contract (docs/DESIGN.md) =====
A 32px icon button is fine with a mouse (a precise pointer plus hover to confirm the aim) and
not fine with a thumb. So the minimum grows ONLY where there is no precise pointer, which on
this app's real second device — a tablet — is exactly where it is needed.
+12 -2
View File
@@ -38,6 +38,10 @@ const App = lazyWithPreload(() => import("./App"));
const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
const Terms = lazy(() => import("./components/legal/Terms"));
const WatchPage = lazy(() => import("./components/WatchPage"));
// The design lab (E28): the living style guide, rendered with the app's real CSS so a design decision
// is taken on what will ship. DEV only — Vite folds the condition to `false` in a production build,
// the import becomes dead code and the chunk is never emitted (devOnlyGuard fails the build if it is).
const DesignLab = import.meta.env.DEV ? lazy(() => import("./design-lab/DesignLab")) : null;
const queryClient = createQueryClient();
@@ -49,13 +53,19 @@ de.theme ||= "dark";
de.scheme ||= "midnight";
const path = window.location.pathname;
const isPublicPage = path === "/privacy" || path === "/terms" || path.startsWith("/watch/");
const isLab = DesignLab !== null && path === "/design-lab";
const isPublicPage =
path === "/privacy" || path === "/terms" || path.startsWith("/watch/") || isLab;
// Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens:
// the two waits then overlap rather than queue, and the gate usually opens straight onto the app. A
// failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary.
if (!isPublicPage) App.preload().catch(() => {});
const root =
path === "/privacy" ? (
isLab && DesignLab ? (
<FloatingTree>
<DesignLab />
</FloatingTree>
) : path === "/privacy" ? (
<PrivacyPolicy />
) : path === "/terms" ? (
<Terms />