From 477271907e9b72b44fecb0c7930b9d95cd6c2155 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 02:21:20 +0200 Subject: [PATCH] feat(design): batch A recorded, batch B in the design lab (E28 S2b) DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows; density stays open as B1. The lab keeps only the chosen A renderings as reference and adds batch B: density, the scheme set (measured accent hue gaps and light contrast), state layers, the page-chrome model, surface per role, the public watch page, the nav rail, the loading form and where the header + fade edge goes. --- docs/DESIGN.md | 71 ++- frontend/src/design-lab/DesignLab.tsx | 38 +- frontend/src/design-lab/batchA.tsx | 300 ++-------- frontend/src/design-lab/batchB.tsx | 565 ++++++++++++++++++ frontend/src/design-lab/batchBChrome.tsx | 719 +++++++++++++++++++++++ frontend/src/design-lab/labColor.ts | 33 ++ frontend/src/design-lab/labKit.tsx | 29 + 7 files changed, 1481 insertions(+), 274 deletions(-) create mode 100644 frontend/src/design-lab/batchB.tsx create mode 100644 frontend/src/design-lab/batchBChrome.tsx create mode 100644 frontend/src/design-lab/labColor.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fbb707f5..b0b27dff 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -54,8 +54,8 @@ style guide. `.reveal` class), or it is removed on touch because the same action is reachable another way (`.reveal-hover`). [WCAG 1.4.13, Nielsen #6] - **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area: - overlapping hit areas fire the wrong control. [HIG, WCAG 2.5.8] _Which controls this covers beyond - `Button` is decision A3._ + overlapping hit areas fire the wrong control. It covers every control primitive, not only `Button` + (A3, under Controls). [HIG, WCAG 2.5.8] - **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page when an input gets focus. - **Below 768px** (a narrow window, split screen): what replaces the side rails is **Pending (C)**. @@ -63,23 +63,40 @@ style guide. ## Tokens - **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger - --warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark +--warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark and light. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3, - 1.4.11] _The light-theme accent fix is decision B; the input edge token is decision A4._ + 1.4.11] _The scheme set and the light-theme accent are decision B2._ The input edge token is set under + Controls (A4). - **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a px font size or a px-frozen box is an eslint error. -- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. _Buttons, inputs, triggers - and icon buttons: decisions A1, A2, A4, A5. Surfaces per role: decision B._ +- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and + form fields `rounded-lg`; toolbar triggers and search a pill (Controls). _Surfaces per role: decision B5._ - **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail < - overlay < popover < tooltip`). No ad-hoc z-index. [maintainability] +overlay < popover < tooltip`). No ad-hoc z-index. [maintainability] -## Controls — **Pending (batch A)** +## Controls — _Decided_ (batch A; density **Pending**, B1) -Open in the lab: **A1** button shape and size rule · **A2** icon-button sizes and the on-media button · -**A3** the touch size beyond `Button` · **A4** the input primitive · **A5** the dropdown trigger · -**A6** checkbox, switch, slider. +The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3. + +- **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12, + dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel + headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. _The rung + heights (standard 38/32/26 or compact 32/28/24) are decision B1._ [HIG, Material] +- **Icon buttons (A2):** two sizes, r8: the smaller in rows and dense toolbars, the larger on its own. On a + picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only. +- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`, + `Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse + sizes don't change. [HIG, WCAG 2.5.8] +- **Input (A4):** one bordered input primitive. The edge is `color-mix(in srgb, var(--fg) 55%, var(--bg))`, + ≥ 3:1 against the page in every scheme and mode. A form field is r8 at the `md` height; a toolbar search + is a pill. Text is 1rem on a coarse pointer. [WCAG 1.4.11] +- **Dropdown trigger (A5):** a pill in a toolbar (where the pill means "a filter or a choice"), r8 in a form + (it lines up with the inputs). +- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color`; the whole + labelled row is the 44px target. Keyboard, screen reader and forced-colours support come from the browser. + +Also in force: -Already in force: - An action button is `ui/Button` with a `SIZES` rung. A hand-rolled ` + ); +} + +const GHOST = "text-muted hover:text-fg hover:bg-card"; +const OUTLINE = "border border-border text-muted hover:text-accent hover:border-accent"; +const GLASS = "glass-card glass-hover hover:text-accent"; + +function IconBtn({ box, children }: { box: string; children: ReactNode }) { + return ( + + ); +} + +function DensitySet({ l }: { l: Ladder }) { + return ( + <> + +
+ + + Sort: Newest + + + }> + Sync now + + + + +
+
+ +
+ +
+ + Cancel + + }> + Delete + + + Save + +
+
+
+ +
+ Download failed · HTTP 403 + + + Retry + + + + + + + + +
+
+ +
+ }> + Download + + + + +
+
+ + ); +} + +export function B1() { + return ( + + + + + + + + + ); +} + +// ── B2 · the scheme set ────────────────────────────────────────────────────────────────────────── +// A proposed scheme is rendered by scoping the tokens on a wrapper: every utility resolves var(--…) +// at the use site (the `@theme inline` block in index.css), so bg-accent inside the tile paints the +// tile's accent, not the page's. The numbers under a tile come from the hex values, not the probe. + +type Pal = { + bg: string; + surface: string; + card: string; + border: string; + fg: string; + muted: string; + accent: string; + accentFg: string; +}; +type Mode = "dark" | "light"; +type SchemeDef = { name: string; dark: Pal; light: Pal }; + +const STATUS: Record> = { + dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" }, + light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" }, +}; + +const pal = ( + bg: string, + surface: string, + card: string, + border: string, + fg: string, + muted: string, + accent: string, + accentFg: string, +): Pal => ({ bg, surface, card, border, fg, muted, accent, accentFg }); + +// Today's six, copied from index.css (the scheme blocks). +const N = { + midnight: { + dark: ["#0b1020", "#121a2e", "#161f38", "#243049", "#e6e9f0", "#97a3c0"], + light: ["#f4f6fc", "#ffffff", "#ffffff", "#dde3f0", "#1a2238", "#5b6685"], + }, + forest: { + dark: ["#0a1512", "#0f1f1a", "#12241e", "#1f3a30", "#e6f0ec", "#90b1a4"], + light: ["#f1f7f4", "#ffffff", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60"], + }, + slate: { + dark: ["#15171c", "#1b1e25", "#1f232b", "#2c313b", "#e7e9ee", "#9aa1ad"], + light: ["#f6f7f9", "#ffffff", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e"], + }, + youtube: { + dark: ["#0f0f0f", "#181818", "#1f1f1f", "#303030", "#f1f1f1", "#aaaaaa"], + light: ["#ffffff", "#f9f9f9", "#ffffff", "#e5e5e5", "#0f0f0f", "#606060"], + }, + starship: { + dark: ["#050b16", "#0b1728", "#101f36", "#1f3557", "#dfeaff", "#8aa6cc"], + light: ["#eef4fb", "#ffffff", "#ffffff", "#d3e0f0", "#0b1b30", "#4a5f7d"], + }, + matrix: { + dark: ["#030a06", "#08130d", "#0b1c12", "#163d27", "#cfe8d6", "#6fae86"], + light: ["#f0f7f1", "#ffffff", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56"], + }, +} as const; + +function scheme( + name: string, + id: keyof typeof N, + dark: [string, string], + light: [string, string], +): SchemeDef { + const [db, ds, dc, dbo, df, dm] = N[id].dark; + const [lb, ls, lc, lbo, lf, lm] = N[id].light; + return { + name, + dark: pal(db, ds, dc, dbo, df, dm, dark[0], dark[1]), + light: pal(lb, ls, lc, lbo, lf, lm, light[0], light[1]), + }; +} + +const TODAY: SchemeDef[] = [ + scheme("Midnight", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]), + scheme("Forest", "forest", ["#2dd4bf", "#04110d"], ["#0d9488", "#ffffff"]), + scheme("Slate", "slate", ["#e8913c", "#1a1206"], ["#d97706", "#ffffff"]), + scheme("YouTube", "youtube", ["#ff3b46", "#ffffff"], ["#ff0033", "#ffffff"]), + scheme("Starship", "starship", ["#38b6ff", "#04101f"], ["#0b6bd6", "#ffffff"]), + scheme("Matrix", "matrix", ["#46b87c", "#04170c"], ["#12a150", "#ffffff"]), +]; + +// The proposal: four schemes, four hue families far from each other and from red / amber / green. +// Only the accents move; every background and text colour stays as it is. +const PROPOSED: SchemeDef[] = [ + scheme("Midnight · blue", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]), + scheme("Forest · cyan", "forest", ["#22d3ee", "#04110d"], ["#0e7490", "#ffffff"]), + scheme("Slate · fuchsia", "slate", ["#e879f9", "#1a0620"], ["#a21caf", "#ffffff"]), + scheme("Matrix · lime", "matrix", ["#a3e635", "#04170c"], ["#4d7c0f", "#ffffff"]), +]; + +const MIN_GAP = 30; + +function Metric({ ok, children }: { ok: boolean; children: ReactNode }) { + return ( + {children} + ); +} + +function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) { + const p = s[mode]; + const st = STATUS[mode]; + const style = { + "--bg": p.bg, + "--surface": p.surface, + "--card": p.card, + "--border": p.border, + "--fg": p.fg, + "--muted": p.muted, + "--accent": p.accent, + "--accent-fg": p.accentFg, + "--danger": st.danger, + "--danger-fg": st.dangerFg, + "--warn": st.warn, + "--success": st.success, + } as CSSProperties; + const gaps = (["danger", "warn", "success"] as const).map((k) => ({ + k, + g: Math.round(hueGap(p.accent, st[k])), + })); + const worst = gaps.reduce((a, b) => (b.g < a.g ? b : a)); + const text = hexContrast(p.accent, p.bg); + const onFill = hexContrast(p.accentFg, p.accent); + return ( +
+ + {s.name} · {mode} + +
+ Save + Delete +
+
+ Filter + Retrying + Done +
+ + Text with a link in it. + + {/* Measured from the hex values; drawn in the tile's own status colours. */} + + = MIN_GAP}>{`closest status: ${worst.k} ${worst.g}°`} + = 4.5}>{`accent as text ${text.toFixed(2)}:1`} + = 4.5}>{`button text ${onFill.toFixed(2)}:1`} + +
+ ); +} + +function SchemeGrid({ set }: { set: SchemeDef[] }) { + return ( +
+ {set.flatMap((s) => + (["dark", "light"] as const).map((m) => ), + )} +
+ ); +} + +export function B2() { + return ( + + + + + + + + + ); +} + +// ── B3 · state layers ──────────────────────────────────────────────────────────────────────────── +// A state is a layer of a colour mixed over whatever the control sits on, so it works on the page, +// on a card and on glass alike. `bg-card` as a hover could not: on a white light-theme card it is the +// card's own colour. Each control is shown at rest, hovered and pressed, then once live. + +type Layers = { + name: string; + /** static hover / pressed classes for the ghost & row sample */ + gh: string; + gp: string; + /** static hover / pressed classes for the filled sample */ + fh: string; + fp: string; + /** live classes */ + ghostLive: string; + filledLive: string; + /** colour expressions for the measured readout */ + ghostOnPage: string; + rowOnCard: string; + filledHover: string; +}; + +const mix = (c: string, p: number, over: string) => `color-mix(in srgb, var(${c}) ${p}%, ${over})`; + +const LAYERS: Record<"strong" | "material" | "today", Layers> = { + strong: { + name: "10% / 16%", + gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]", + gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]", + fh: "bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] text-accent-fg", + fp: "bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))] text-accent-fg", + ghostLive: + "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]", + filledLive: + "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]", + ghostOnPage: mix("--fg", 10, "var(--bg)"), + rowOnCard: mix("--fg", 10, "var(--card)"), + filledHover: mix("--accent-fg", 14, "var(--accent)"), + }, + material: { + name: "8% / 12% (Material 3)", + gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_8%,transparent)]", + gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]", + fh: "bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] text-accent-fg", + fp: "bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))] text-accent-fg", + ghostLive: + "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_8%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]", + filledLive: + "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))]", + ghostOnPage: mix("--fg", 8, "var(--bg)"), + rowOnCard: mix("--fg", 8, "var(--card)"), + filledHover: mix("--accent-fg", 8, "var(--accent)"), + }, + today: { + name: "today", + gh: "text-fg bg-card", + gp: "text-fg bg-card", + fh: "bg-accent text-accent-fg opacity-90", + fp: "bg-accent text-accent-fg opacity-90", + ghostLive: "text-muted hover:text-fg hover:bg-card", + filledLive: "btn-primary", + ghostOnPage: "var(--card)", + rowOnCard: "var(--card)", + filledHover: "color-mix(in srgb, var(--accent) 90%, var(--bg))", + }, +}; + +const cell = "inline-flex h-8 items-center justify-center rounded-lg px-3 text-sm font-medium"; +const HOVER_MIN = 1.15; + +function StateRow({ + label, + rest, + hover, + pressed, +}: { + label: string; + rest: string; + hover: string; + pressed: string; +}) { + return ( +
+ {label} + Rest + Hover + Pressed +
+ ); +} + +function LayerSet({ l }: { l: Layers }) { + return ( + <> + + + + + +
+ +
+ +
+ + + + + +
+ + +
+
+ {["Watch later", "Add to playlist", "Copy link"].map((t) => ( + + ))} +
+
+ + ); +} + +export function B3() { + return ( + + + + + + + + + + + + ); +} diff --git a/frontend/src/design-lab/batchBChrome.tsx b/frontend/src/design-lab/batchBChrome.tsx new file mode 100644 index 00000000..3d650915 --- /dev/null +++ b/frontend/src/design-lab/batchBChrome.tsx @@ -0,0 +1,719 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; +import { + Bell, + Check, + ChevronDown, + Download, + Home, + ListVideo, + Loader2, + Lock, + PlayCircle, + Repeat, + Rss, + Settings, + SlidersHorizontal, + Tv, +} from "lucide-react"; +import { Decision, Sample, Variant } from "./labKit"; + +// E28 S2 · Batch B, part 2 — surfaces and page chrome: the page-chrome model, the surface per role, +// the public watch page, the nav rail, the loading form, and where the header + fade edge goes. +// The mini pages are real scroll boxes: scroll them to see what stays and what slides under. + +const ico = "w-4 h-4"; + +/** The north-star edge (DataTable's sticky thead): a crisp band, and a short gradient of the colour + * it sits on just below it, so content dissolves as it slides under. Literal strings per colour — + * Tailwind only generates classes it can read whole. */ +const FADE_BELOW = + "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']"; +const FADE_BELOW_PANEL = + "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']"; +const FADE_ABOVE_PANEL = + "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']"; + +function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) { + return ( + +
+
{children}
+
+
+ ); +} + +function Cards({ n = 12 }: { n?: number }) { + return ( +
+ {Array.from({ length: n }, (_, i) => ( +
+
+ Video title number {i + 1} + Channel · 2 days ago +
+ ))} +
+ ); +} + +const tab = (on: boolean) => + clsx( + "rounded-lg px-2.5 py-1 text-xs", + on ? "bg-accent text-accent-fg" : "text-muted hover:text-fg", + ); +const pillCtl = "glass-card inline-flex h-7 items-center gap-1 rounded-full border px-2.5 text-xs"; + +function Tabs() { + return ( +
+ Queue + Library + History +
+ ); +} + +function Filters() { + return ( +
+ + State: Unwatched + + + Date: Any + + + Sort: Newest + +
+ ); +} + +// ── B4 · the page-chrome model ─────────────────────────────────────────────────────────────────── +function BandToolbar({ collapsed }: { collapsed?: boolean }) { + return ( +
+
+ + {collapsed && ( + + Filters · 3 + + )} +
+ {!collapsed && } +
+ ); +} + +function GlassToolbar() { + return ( +
+
+ + +
+
+ ); +} + +function InlineToolbar() { + return ( +
+ + +
+ ); +} + +export function B4() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +// ── B5 · surface per role ──────────────────────────────────────────────────────────────────────── +type Roles = { dialog: string; card: string; menu: string; item: string; tip: string }; + +function Surfaces({ r }: { r: Roles }) { + return ( +
+
+ Dialog +
+
Card
+
Card
+
+
+ Save +
+
+
+
+ {["Watch later", "Add to playlist", "Copy link"].map((t, i) => ( + + {t} + + ))} +
+ Tooltip +
+
+ ); +} + +export function B5() { + return ( + + + + + + + + + ); +} + +// ── B6 · the public watch page ─────────────────────────────────────────────────────────────────── +function WatchMock({ app }: { app: boolean }) { + const btn = app + ? "glass-card glass-hover rounded-lg h-8 px-3" + : "rounded-lg bg-surface hover:bg-card px-3 py-2"; + return ( +
+
+ + Siftlode +
+
+ the browser's own video controls +
+
+
+
Building a home lab on a budget
+
Techno Tim
+
+ + + + Download + + +
+
+ ); +} + +export function B6() { + return ( + + + + + + + + + ); +} + +// ── B7 · the nav rail ──────────────────────────────────────────────────────────────────────────── +const NAV = [Home, Rss, ListVideo, Tv, Download, Bell]; + +function Rail({ kind }: { kind: "solid" | "soft" | "indicator" }) { + return ( +
+ {NAV.map((I, i) => { + const on = i === 1; + if (kind === "indicator") + return ( + + + + + + {["Home", "Feed", "Lists", "Plex", "Saved", "Alerts"][i]} + + + ); + return ( + + {kind === "soft" && on && ( + + )} + + + ); + })} + + + + +
+ ); +} + +export function B7() { + return ( + + + + + + + + + + + + ); +} + +// ── B8 · the loading form ──────────────────────────────────────────────────────────────────────── +function SkeletonGrid() { + return ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+
+
+
+
+ ))} +
+ ); +} + +function SpinnerBlock() { + return ( +
+ + Loading… + +
+ ); +} + +function SavingButton() { + return ( + + Saving + + ); +} + +export function B8() { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +// ── B9 · where the fade edge goes ──────────────────────────────────────────────────────────────── +const LINES = Array.from({ length: 14 }, (_, i) => `Row ${i + 1} · Some content that scrolls`); + +function ScrollBody({ children }: { children?: ReactNode }) { + return ( +
+ {children} + {LINES.map((l) => ( + + {l} + + ))} +
+ ); +} + +function FadeToolbar() { + return ( + +
+ Title · Channel · Date +
+ + + ); +} + +function FadeTabs() { + return ( + +
+
+ +
+
+ +
+
+
+ ); +} + +function FadeButtons() { + const depth = + "bg-[linear-gradient(to_bottom,color-mix(in_srgb,#fff_14%,var(--accent)),var(--accent)_55%,color-mix(in_srgb,#000_14%,var(--accent)))] text-accent-fg shadow-[inset_0_-1px_0_rgb(0_0_0/0.25)]"; + const glassDepth = + "glass-card bg-[linear-gradient(to_bottom,color-mix(in_srgb,var(--fg)_8%,transparent),transparent)]"; + return ( + +
+ + Flat + + + Depth + + + Flat glass + + + Depth glass + +
+
+ ); +} + +function FadeChips() { + const mask = + "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)"; + const chips = [ + "Unwatched", + "Shorts", + "Live", + "Music", + "Tech", + "Science", + "News", + "Gaming", + "Travel", + ]; + return ( + +
+
+ {chips.map((c) => ( + + {c} + + ))} +
+
+
+ ); +} + +function FadeCard() { + return ( + +
+ {[false, true].map((fade) => ( +
+
+ {fade && ( + + )} +
+ + {fade ? "Faded edge" : "Hard edge (today)"} + +
+ ))} +
+
+ ); +} + +function FadeDialog() { + return ( + +
+
+ Edit details +
+
+ +
+
+ + Cancel + + + Save + +
+
+
+ ); +} + +export function B9() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/design-lab/labColor.ts b/frontend/src/design-lab/labColor.ts new file mode 100644 index 00000000..fbc98764 --- /dev/null +++ b/frontend/src/design-lab/labColor.ts @@ -0,0 +1,33 @@ +// ── Fixed-palette maths ────────────────────────────────────────────────────────────────────────── +// A proposed scheme is not in the CSS yet, so it can't be measured through the probe: these work on +// literal hex values instead. Hue distance is taken in OKLCH, where equal angles look equally far +// apart, unlike HSL's. + +const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); +const lin2 = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + +export function hexContrast(a: string, b: string): number { + const l = (h: string) => { + const [r = 0, g = 0, bl = 0] = rgb(h).map(lin2); + return 0.2126 * r + 0.7152 * g + 0.0722 * bl; + }; + const x = l(a); + const y = l(b); + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +} + +function hue(hex: string): number { + const [r = 0, g = 0, b = 0] = rgb(hex).map(lin2); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + return ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360; +} + +/** The OKLCH hue angle between two colours, 0..180°. */ +export function hueGap(a: string, b: string): number { + const d = Math.abs(hue(a) - hue(b)) % 360; + return Math.min(d, 360 - d); +} diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx index 473f96ec..4b08758a 100644 --- a/frontend/src/design-lab/labKit.tsx +++ b/frontend/src/design-lab/labKit.tsx @@ -77,6 +77,35 @@ export function Variant({ ); } +/** A decision already taken: the chosen rendering only, with the rule it became. The lab's + * reference half — the living style guide the open decisions sit on top of. */ +export function Reference({ + id, + code, + title, + rule, + children, +}: { + id: string; + code: string; + title: string; + rule: string; + children: ReactNode; +}) { + return ( +
+
+
+ {code} ✓ +

{title}

+
+

{rule}

+
+
{children}
+
+ ); +} + /** A small caption above a sample inside a variant. */ export function Sample({ label, children }: { label: string; children: ReactNode }) { return (