import { useSyncExternalStore, type ReactNode } from "react"; import clsx from "clsx"; import { Badge } from "../components/ui/Badge"; import { SURFACE } from "../components/ui/surfaces"; // Building blocks of the design lab. A taken decision is a : the chosen rendering with the // rule it became. An open design round shows its questions as s with their s side // by side, until the owner picks. Everything // is static markup on purpose: a rendering that only exists here must not need app state. /** A decision already taken: the chosen rendering only, with the rule it became. The lab's * reference half — the living style guide. */ export function Reference({ id, code, title, rule, children, }: { id: string; code: string; title: string; rule: string; children: ReactNode; }) { return (
{code} ✓

{title}

{rule}

{children}
); } /** An open question of a design round: what the app does today, then the variants side by side. */ export function Decision({ id, code, title, question, now, children, }: { id: string; code: string; title: string; question: string; /** What the app does today. */ now: ReactNode; children: ReactNode; }) { return (
{code}

{title}

{question}

Today: {now}

{children}
); } /** One answer to a Decision; the recommended one carries the accent edge. */ export function Variant({ letter, name, recommended = false, why, children, }: { letter: string; name: string; recommended?: boolean; why: string; children: ReactNode; }) { return (
{letter}

{name}

{recommended && Recommended}
{children}

{why}

); } /** A small caption above a sample inside a variant. */ export function Sample({ label, children }: { label: string; children: ReactNode }) { return (
{label} {children}
); } // ── 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 // '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 ( {label}: {value}:1 {ok ? "✓" : `✗ (needs ${min}:1)`} ); }