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
+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>
);
}