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:
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user