mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
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:
parent
03bf1ed08b
commit
2574b21acc
32
unified-design-system/screen-size.md
Normal file
32
unified-design-system/screen-size.md
Normal 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.
|
||||
146
unified-design-system/scripts/ramp_check.py
Normal file
146
unified-design-system/scripts/ramp_check.py
Normal 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)
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
21
web_ui/dist/tokens.css
vendored
21
web_ui/dist/tokens.css
vendored
@ -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 */
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
111
web_ui/src/tokens.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user