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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 14:50:43 +02:00
parent 03bf1ed08b
commit 2574b21acc
9 changed files with 433 additions and 56 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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.

View File

@ -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.

View File

@ -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 */

View File

@ -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.",

View File

@ -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.
},
},
},
});
});
}

View File

@ -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 */

111
web_ui/src/tokens.test.ts Normal file
View File

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