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