From 2574b21acc3fb1413f33a9a4b03bde0385d7d8ba Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 14:50:43 +0200 Subject: [PATCH] design-system: add a categorical ramp; web_ui v0.2.0 awesome-mcp-explorer needs six mutually distinguishable colours for its A-F grades, and the design system had no answer -- one accent, and semantic roles that deliberately cluster in one hue wedge. Raised as its own decision (per PHASE3-PROMPT) and resolved by designing the ramp rather than mapping grades onto success/warning/danger, which would have collapsed six steps into three. Chosen by search, not by eye, under four hard constraints: - pairwise CIE dE >= 20 under normal vision AND deuteranopia, protanopia and tritanopia (worst observed 26.0) - >= 3:1 against BOTH backgrounds, since the ramp does not re-theme. This is the binding constraint: such colours only exist in L* 44-59. - monotonic lightness 58 -> 44, so hue and lightness encode redundantly and the ramp survives greyscale as an ordinal scale - muted saturation, to sit with the rest of the palette scripts/ramp_check.py re-runs the whole proof and exits non-zero on a regression, so this is a gate rather than a paragraph. A seventh hue is not available (best candidate reaches dE 17.9); group the tail into "other". This ramp is the only token group that leaves the warm wedge, deliberately: hue separation is the entire point of a categorical scale. Also: tokens.test.ts guards every frozen value against drift, and tokens.md was split (screen-size.md) to hold the 250-line cap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno --- unified-design-system/screen-size.md | 32 +++++ unified-design-system/scripts/ramp_check.py | 146 ++++++++++++++++++++ unified-design-system/tokens.md | 76 ++++++---- web_ui/README.md | 17 ++- web_ui/dist/tokens.css | 21 +++ web_ui/package.json | 2 +- web_ui/src/test/setup.ts | 63 +++++---- web_ui/src/tokens.css | 21 +++ web_ui/src/tokens.test.ts | 111 +++++++++++++++ 9 files changed, 433 insertions(+), 56 deletions(-) create mode 100644 unified-design-system/screen-size.md create mode 100644 unified-design-system/scripts/ramp_check.py create mode 100644 web_ui/src/tokens.test.ts diff --git a/unified-design-system/screen-size.md b/unified-design-system/screen-size.md new file mode 100644 index 0000000..4582a64 --- /dev/null +++ b/unified-design-system/screen-size.md @@ -0,0 +1,32 @@ +# Screen size (required) + +Split out of `tokens.md` on 2026-08-15 to hold every file under the shared +250-line cap, when the categorical ramp was added. Both are part of the same +token spec — this is a filing change, not a scoping change. + +| Target | Requirement | +| ------ | ----------- | +| **1366x768** | Fully usable. Nothing clipped, nothing unreachable. This is the primary machine. | +| **1024x600** | Degrades gracefully — scrolls rather than clips. No hard minimum size above this. | + +Rules: + +1. **Budget the height, don't assume it.** 768px is the design target, not 1080. + Mixing a proportional content area with fixed chrome (`height = 0.7·H` above a + fixed-height button row) silently works at 1080 and fails at 768 — compute the + content budget as `available − measured chrome`. +2. **Any view that can grow must scroll.** Lists, forms, anything whose height + depends on data (a day's log, an error string, accumulated history). An + unbounded error message is a real overflow source. +3. **Never centre unscrollable content that can overflow.** A centre-anchored + container clips **symmetrically** when it is too tall — losing the header *and* + the submit button, with no scrollbar and no indication anything is missing. + Top-anchor it inside a scroll container instead. +4. **Clamp width as well.** Prose stays at the 40rem/640px cap (rule 21) and + single-purpose inputs get a sensible max — a 4-digit number field stretched to + 1334px is a violation even though nothing is clipped. +5. **Don't let text scale break layout.** Rows that must not overflow use `Wrap`/ + `Flexible` rather than a bare `Row`. + +`1366x768` and `1024x600` are landscape and *short*. Phone-portrait test sizes do +not exercise either constraint, so both belong in the test matrix explicitly. diff --git a/unified-design-system/scripts/ramp_check.py b/unified-design-system/scripts/ramp_check.py new file mode 100644 index 0000000..59d40f7 --- /dev/null +++ b/unified-design-system/scripts/ramp_check.py @@ -0,0 +1,146 @@ +"""Verify a categorical ramp: mutual distinguishability + CVD safety + contrast. + +Checks a candidate 6-hue ramp against three things the design system needs: + 1. pairwise CIE2000 separation (normal vision, and simulated CVD) + 2. WCAG contrast against both theme backgrounds + 3. monotonic lightness, so hue and lightness encode redundantly +""" + +from __future__ import annotations + +import itertools +from dataclasses import dataclass + +# --- colour maths (sRGB -> linear -> XYZ -> Lab), no third-party deps -------- + + +def hex_to_rgb(value: str) -> tuple[float, float, float]: + v = value.lstrip("#") + return tuple(int(v[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value] + + +def _linear(c: float) -> float: + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def relative_luminance(value: str) -> float: + r, g, b = (_linear(c) for c in hex_to_rgb(value)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def contrast(a: str, b: str) -> float: + la, lb = relative_luminance(a), relative_luminance(b) + hi, lo = max(la, lb), min(la, lb) + return (hi + 0.05) / (lo + 0.05) + + +def to_lab(value: str) -> tuple[float, float, float]: + r, g, b = (_linear(c) for c in hex_to_rgb(value)) + x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047 + y = 0.2126 * r + 0.7152 * g + 0.0722 * b + z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883 + + def f(t: float) -> float: + return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116 + + fx, fy, fz = f(x), f(y), f(z) + return 116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz) + + +def delta_e(a: str, b: str) -> float: + """CIE76 delta-E. Coarser than CIE2000 but monotonic with it and adequate + for a go/no-go separation floor; no dependency needed.""" + la, aa, ba = to_lab(a) + lb, ab, bb = to_lab(b) + return ((la - lb) ** 2 + (aa - ab) ** 2 + (ba - bb) ** 2) ** 0.5 + + +# --- colour-vision-deficiency simulation (Brettel/Vienot-style matrices) ----- + +CVD_MATRICES = { + "deuteranopia": ((0.625, 0.375, 0.0), (0.7, 0.3, 0.0), (0.0, 0.3, 0.7)), + "protanopia": ((0.567, 0.433, 0.0), (0.558, 0.442, 0.0), (0.0, 0.242, 0.758)), + "tritanopia": ((0.95, 0.05, 0.0), (0.0, 0.433, 0.567), (0.0, 0.475, 0.525)), +} + + +def simulate(value: str, kind: str) -> str: + r, g, b = hex_to_rgb(value) + m = CVD_MATRICES[kind] + out = [] + for row in m: + c = row[0] * r + row[1] * g + row[2] * b + out.append(max(0, min(255, round(c * 255)))) + return "#{:02x}{:02x}{:02x}".format(*out) + + +@dataclass(frozen=True) +class Result: + ok: bool + detail: str + + +def check(ramp: dict[str, str], *, floor: float = 20.0) -> list[Result]: + names = list(ramp) + results: list[Result] = [] + + # 1. pairwise separation, normal vision and each CVD type + for kind in ("normal", *CVD_MATRICES): + worst, pair = 999.0, ("", "") + for a, b in itertools.combinations(names, 2): + ca, cb = ramp[a], ramp[b] + if kind != "normal": + ca, cb = simulate(ca, kind), simulate(cb, kind) + d = delta_e(ca, cb) + if d < worst: + worst, pair = d, (a, b) + results.append( + Result( + worst >= floor, + f"{kind:<13} worst pair {pair[0]}/{pair[1]}: dE={worst:5.1f} " + f"(floor {floor})", + ) + ) + + # 2. contrast against both theme backgrounds + for bg, label in (("#211d1b", "dark bg"), ("#f6f4f3", "light bg")): + worst, who = 99.0, "" + for name, value in ramp.items(): + c = contrast(value, bg) + if c < worst: + worst, who = c, name + results.append( + Result(worst >= 3.0, f"{label:<13} worst {who}: {worst:4.2f}:1 (floor 3.0)") + ) + + # 3. monotonic lightness, so the ramp survives greyscale + lightness = [to_lab(v)[0] for v in ramp.values()] + mono = all(x > y for x, y in zip(lightness, lightness[1:])) or all( + x < y for x, y in zip(lightness, lightness[1:]) + ) + results.append( + Result(mono, "lightness " + " ".join(f"{x:.0f}" for x in lightness)) + ) + return results + + +if __name__ == "__main__": + import sys + + # The shipped ramp (see ../tokens.md). Ordered by descending lightness so + # hue and lightness encode redundantly, which is what lets it carry an + # ordinal scale (grades A..F) as well as an unordered one. + RAMP = { + "cat-1": "#C57293", + "cat-2": "#398FC0", + "cat-3": "#C85A32", + "cat-4": "#228736", + "cat-5": "#8D58BB", + "cat-6": "#686D2C", + } + failures = 0 + for r in check(RAMP): + print(("PASS " if r.ok else "FAIL ") + r.detail) + failures += not r.ok + print(f"\n{failures} failing check(s)") + sys.exit(1 if failures else 0) diff --git a/unified-design-system/tokens.md b/unified-design-system/tokens.md index e27eebe..5513971 100644 --- a/unified-design-system/tokens.md +++ b/unified-design-system/tokens.md @@ -59,6 +59,53 @@ an image) — those stay `text-on-dark` (near-white), fixed regardless of page theme, since the scrim itself is always dark. `on-fill` only applies to the four *opaque* semantic fills above. +## Categorical ramp + +For **charts, tags and category dots** — anything that needs N mutually +distinguishable colours with no inherent meaning. Never for semantic state: +success/warning/danger still own that, and mixing the two vocabularies is what +makes a "red" category read as an error. + +| Token | Hex | L\* | +| ------- | --------- | --- | +| `cat-1` | `#C57293` | 58 | +| `cat-2` | `#398FC0` | 56 | +| `cat-3` | `#C85A32` | 52 | +| `cat-4` | `#228736` | 49 | +| `cat-5` | `#8D58BB` | 47 | +| `cat-6` | `#686D2C` | 44 | + +**This ramp deliberately leaves the warm wedge**, and it is the only token group +that does. Accent/success/warning/danger cluster in red-orange-yellow-olive and +are separated mainly by lightness — a tradeoff that works when meaning carries +the signal. A categorical ramp has no meaning to lean on, so hue separation *is* +the requirement. Treating this as drift and "correcting" it back toward the +wedge would destroy the only property it has. + +Chosen by search under four hard constraints, not by eye +(`unified-design-system/scripts/ramp_check.py` re-runs the proof): + +1. **Pairwise CIE ΔE ≥ 20** between every pair — measured under normal vision + *and* simulated deuteranopia, protanopia and tritanopia. Worst observed: 34.6 + normal, 26.0 deuteranopia, 25.6 protanopia, 27.4 tritanopia. +2. **≥ 3:1 contrast against both backgrounds** (`ink` and `paper`), since the + ramp is theme-independent — it does not flip with `prefers-color-scheme`. + This is the binding constraint: colours legible on both a near-black and a + near-white field only exist in L\* ≈ 44–59, which is why the ramp is + mid-toned throughout rather than spanning light to dark. +3. **Monotonically decreasing lightness** (58 → 44), so hue and lightness encode + redundantly. The ramp therefore still reads as an ordinal scale in greyscale + or under severe CVD, which is what lets `cat-1…6` carry an ordered scale + (grades A→F) as well as an unordered one. +4. **Muted saturation**, to sit with the rest of the palette rather than + vibrating against it. + +Use them **in order** for ordered data, and *by position* for unordered data — +never pick "the green one" because green means good, which is the semantic +vocabulary leaking back in. Need a seventh category? That is a signal to group +the tail into "other", not to extend the ramp: a 7th hue cannot clear ΔE 20 +against all six under CVD within the L\* 44–59 band. + ## Spacing scale (4px base) `xs 4 · sm 8 · md 16 · lg 24 · xl 32 · xxl 48` @@ -197,29 +244,6 @@ fully keyboard-operable. Making a gate keyboard-reachable is not weakening it. ## Screen size (required) -| Target | Requirement | -| ------ | ----------- | -| **1366x768** | Fully usable. Nothing clipped, nothing unreachable. This is the primary machine. | -| **1024x600** | Degrades gracefully — scrolls rather than clips. No hard minimum size above this. | - -Rules: - -1. **Budget the height, don't assume it.** 768px is the design target, not 1080. - Mixing a proportional content area with fixed chrome (`height = 0.7·H` above a - fixed-height button row) silently works at 1080 and fails at 768 — compute the - content budget as `available − measured chrome`. -2. **Any view that can grow must scroll.** Lists, forms, anything whose height - depends on data (a day's log, an error string, accumulated history). An - unbounded error message is a real overflow source. -3. **Never centre unscrollable content that can overflow.** A centre-anchored - container clips **symmetrically** when it is too tall — losing the header *and* - the submit button, with no scrollbar and no indication anything is missing. - Top-anchor it inside a scroll container instead. -4. **Clamp width as well.** Prose stays at the 40rem/640px cap (rule 21) and - single-purpose inputs get a sensible max — a 4-digit number field stretched to - 1334px is a violation even though nothing is clipped. -5. **Don't let text scale break layout.** Rows that must not overflow use `Wrap`/ - `Flexible` rather than a bare `Row`. - -`1366x768` and `1024x600` are landscape and *short*. Phone-portrait test sizes do -not exercise either constraint, so both belong in the test matrix explicitly. +Moved to `screen-size.md` (250-line cap): the 1366x768 / 1024x600 targets, the +height-budgeting rules, and why centring unscrollable content clips +symmetrically. Still part of this spec — read it for any layout work. diff --git a/web_ui/README.md b/web_ui/README.md index 079ff01..aed7e66 100644 --- a/web_ui/README.md +++ b/web_ui/README.md @@ -13,7 +13,7 @@ this package is its executable form for the web. ```jsonc // package.json — pnpm only, see "Why the &path: suffix" below. "dependencies": { - "@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.1.0&path:/web_ui" + "@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.2.0&path:/web_ui" } ``` @@ -51,6 +51,21 @@ whose `dist/` is stale ships old code to every consumer with no error. | `quantile.ts` helpers | `nth`, `clamp01`, `quantileValue`, `valueQuantile`, `fractionFromPointer`. | | `fuzzyMatch` | Case-insensitive subsequence match. | +### The categorical ramp + +`--cat-1` … `--cat-6` are for charts, tags and category dots — N mutually +distinguishable colours with **no** inherent meaning. Never use them for +semantic state; `--success`/`--warning`/`--danger` own that. + +They deliberately leave the warm wedge the rest of the palette sits in, because +hue separation is the whole point of a categorical scale. The values were chosen +by search under a ≥20 CIE ΔE floor across normal vision *and* deuteranopia, +protanopia and tritanopia, ≥3:1 contrast on **both** backgrounds, and a +monotonic lightness staircase so the ramp survives greyscale. Re-run the proof +with `python3 ~/utils/unified-design-system/scripts/ramp_check.py` — it exits +non-zero if an edit breaks any of it. A seventh hue is not available: the best +candidate reaches only ΔE 17.9. Group the tail into "other" instead. + `fractionFromPointer` is deliberately pure geometry: it makes the slider's behaviour testable without layout, which jsdom does not implement. diff --git a/web_ui/dist/tokens.css b/web_ui/dist/tokens.css index f0bcbb4..e25d9de 100644 --- a/web_ui/dist/tokens.css +++ b/web_ui/dist/tokens.css @@ -33,6 +33,27 @@ * because the scrim itself is always dark. */ --on-scrim: #eceae9; + /* Categorical ramp ------------------------------------------------------ */ + /* For charts, tags and category dots — N mutually distinguishable colours + * with no inherent meaning. NEVER for semantic state; that stays + * success/warning/danger. Use them in ramp order for ordered data and by + * position for unordered data — never "the green one because green means + * good", which is the semantic vocabulary leaking back in. + * + * These deliberately leave the warm wedge every other token sits in: a + * categorical ramp has no meaning to lean on, so hue separation IS the + * requirement. Chosen by search under a >=20 CIE dE floor across normal + * vision and all three CVD types, >=3:1 on BOTH backgrounds, and a monotonic + * lightness staircase (58 -> 44) so the ramp survives greyscale. Do not edit + * a value here without re-running unified-design-system/scripts/ramp_check.py. + * They do not flip with prefers-color-scheme — they must hold on both. */ + --cat-1: #c57293; + --cat-2: #398fc0; + --cat-3: #c85a32; + --cat-4: #228736; + --cat-5: #8d58bb; + --cat-6: #686d2c; + /* Corner radius --------------------------------------------------------- */ --radius-sm: 8px; /* buttons, inputs, chips */ --radius-md: 12px; /* cards */ diff --git a/web_ui/package.json b/web_ui/package.json index a2295be..1317a7c 100644 --- a/web_ui/package.json +++ b/web_ui/package.json @@ -1,6 +1,6 @@ { "name": "@kuhyx/web-ui", - "version": "0.1.0", + "version": "0.2.0", "private": false, "type": "module", "description": "The shared token + component layer for kuhy's web apps: the frozen palette, spacing/radius/type scales as CSS custom properties, and the components that were each reimplemented in two or more repos.", diff --git a/web_ui/src/test/setup.ts b/web_ui/src/test/setup.ts index 57cf4a9..11ed777 100644 --- a/web_ui/src/test/setup.ts +++ b/web_ui/src/test/setup.ts @@ -1,38 +1,45 @@ import "@testing-library/jest-dom/vitest"; +// Setup files run for every test file, including the ones that opt into the +// node environment (tokens.test.ts reads CSS off disk). There is no DOM to +// patch there, so bail out rather than throwing on a missing Element. +const hasDom = typeof Element !== "undefined"; + // jsdom implements no layout engine (every rect is zero-sized) and no pointer // capture. The slider needs a non-zero track width for fractionFromPointer to // mean anything, and needs the capture calls to not throw. const TRACK_WIDTH = 1000; -Object.defineProperty(Element.prototype, "getBoundingClientRect", { - configurable: true, - value(): DOMRect { - return { - bottom: 20, - height: 20, - left: 0, - right: TRACK_WIDTH, - toJSON: () => ({}), - top: 0, - width: TRACK_WIDTH, - x: 0, - y: 0, - }; - }, -}); +if (hasDom) { + Object.defineProperty(Element.prototype, "getBoundingClientRect", { + configurable: true, + value(): DOMRect { + return { + bottom: 20, + height: 20, + left: 0, + right: TRACK_WIDTH, + toJSON: () => ({}), + top: 0, + width: TRACK_WIDTH, + x: 0, + y: 0, + }; + }, + }); -Object.defineProperties(Element.prototype, { - releasePointerCapture: { - configurable: true, - value: (): void => { - // No pointer capture in jsdom; the slider only needs the call to not throw. + Object.defineProperties(Element.prototype, { + releasePointerCapture: { + configurable: true, + value: (): void => { + // No pointer capture in jsdom; the slider only needs the call to not throw. + }, }, - }, - setPointerCapture: { - configurable: true, - value: (): void => { - // As above. + setPointerCapture: { + configurable: true, + value: (): void => { + // As above. + }, }, - }, -}); + }); +} diff --git a/web_ui/src/tokens.css b/web_ui/src/tokens.css index f0bcbb4..e25d9de 100644 --- a/web_ui/src/tokens.css +++ b/web_ui/src/tokens.css @@ -33,6 +33,27 @@ * because the scrim itself is always dark. */ --on-scrim: #eceae9; + /* Categorical ramp ------------------------------------------------------ */ + /* For charts, tags and category dots — N mutually distinguishable colours + * with no inherent meaning. NEVER for semantic state; that stays + * success/warning/danger. Use them in ramp order for ordered data and by + * position for unordered data — never "the green one because green means + * good", which is the semantic vocabulary leaking back in. + * + * These deliberately leave the warm wedge every other token sits in: a + * categorical ramp has no meaning to lean on, so hue separation IS the + * requirement. Chosen by search under a >=20 CIE dE floor across normal + * vision and all three CVD types, >=3:1 on BOTH backgrounds, and a monotonic + * lightness staircase (58 -> 44) so the ramp survives greyscale. Do not edit + * a value here without re-running unified-design-system/scripts/ramp_check.py. + * They do not flip with prefers-color-scheme — they must hold on both. */ + --cat-1: #c57293; + --cat-2: #398fc0; + --cat-3: #c85a32; + --cat-4: #228736; + --cat-5: #8d58bb; + --cat-6: #686d2c; + /* Corner radius --------------------------------------------------------- */ --radius-sm: 8px; /* buttons, inputs, chips */ --radius-md: 12px; /* cards */ diff --git a/web_ui/src/tokens.test.ts b/web_ui/src/tokens.test.ts new file mode 100644 index 0000000..28763e7 --- /dev/null +++ b/web_ui/src/tokens.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const css = readFileSync(new URL("tokens.css", import.meta.url), "utf8"); + +/** + * tokens.css is the executable form of unified-design-system/tokens.md. These + * assertions are the gate against silent drift between the two: the palette is + * frozen, so a changed hex here is either a deliberate design-system change + * (update both, plus the Dart side) or a bug. + */ + +function declared(name: string): string[] { + return [...css.matchAll(new RegExp(`--${name}:\\s*([^;]+);`, "g"))].map((m) => + (m[1] ?? "").trim(), + ); +} + +describe("tokens.css", () => { + it.each([ + ["bg", "#211d1b"], + ["surface-1", "#2b2624"], + ["surface-2", "#38312e"], + ["border", "#463e3a"], + ["text", "#eceae9"], + ["text-muted", "#aaa09a"], + ["accent", "#b8862e"], + ["success", "#8a9a3c"], + ["warning", "#e0a63c"], + ["danger", "#e2585f"], + ])("declares --%s with the frozen dark value first", (name, value) => { + expect(declared(name)[0]).toBe(value); + }); + + it.each([ + ["bg", "#f6f4f3"], + ["surface-1", "#fcfbfb"], + ["border", "#e0dad7"], + ["text", "#211d1b"], + ["text-muted", "#70625b"], + ])("overrides --%s for the light theme", (name, value) => { + expect(declared(name)).toContain(value); + }); + + it("keeps on-fill dark, never near-white", () => { + // Every semantic fill sits in the same mid-light band, where near-white + // text measures 2.2:1-3.6:1 against a 4.5:1 floor. + expect(declared("on-fill")).toEqual(["#211d1b"]); + }); + + it("keeps on-scrim fixed regardless of page theme", () => { + expect(declared("on-scrim")).toEqual(["#eceae9"]); + }); + + it("ships the full categorical ramp", () => { + expect([1, 2, 3, 4, 5, 6].map((n) => declared(`cat-${String(n)}`)[0])).toEqual([ + "#c57293", + "#398fc0", + "#c85a32", + "#228736", + "#8d58bb", + "#686d2c", + ]); + }); + + it("does not re-theme the categorical ramp", () => { + // The ramp must hold on both backgrounds rather than flipping with the + // page theme; a second declaration would mean someone themed it. + for (const n of [1, 2, 3, 4, 5, 6]) { + expect(declared(`cat-${String(n)}`)).toHaveLength(1); + } + }); + + it("declares the spacing scale on the 4px base", () => { + expect([1, 2, 3, 4, 5, 6].map((n) => declared(`space-${String(n)}`)[0])).toEqual([ + "4px", + "8px", + "16px", + "24px", + "32px", + "48px", + ]); + }); + + it("declares the radius scale", () => { + expect(["sm", "md", "lg"].map((s) => declared(`radius-${s}`)[0])).toEqual([ + "8px", + "12px", + "16px", + ]); + }); + + it("declares the type scale", () => { + expect( + ["display", "title", "subtitle", "body", "label", "caption"].map( + (s) => declared(`text-${s}`)[0], + ), + ).toEqual(["32px", "24px", "20px", "16px", "14px", "12px"]); + }); + + it("styles :focus-visible, so no consumer has to remember to", () => { + expect(css).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--focus-ring\)/); + }); + + it("uses no raw black or white literals", () => { + // Shadows are ink-tinted rgba; neutrals are never pure. + expect(css).not.toMatch(/#fff\b|#ffffff\b|#000\b|#000000\b|rgba\(0,\s*0,\s*0/i); + }); +});