@halftone-ds/tokens 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  The token build behind [Halftone](https://github.com/itsHendri/halftone). One `tokens.json` in the
4
4
  [W3C design-token format](https://www.designtokens.org/tr/2025.10/format/) becomes everything an app reads,
5
- and the build refuses any palette that falls below WCAG AA.
5
+ and every build checks the palette against WCAG AA.
6
6
 
7
7
  ```bash
8
8
  npm install -D @halftone-ds/tokens
9
9
  npx halftone-tokens build # write every output in tokens/tokens.config.json
10
10
  npx halftone-tokens check # write nothing; exit 1 if an output is stale (CI)
11
+ npx halftone-tokens build --strict # fail on contrast instead of warning (or HALFTONE_STRICT=1)
11
12
  npx halftone-tokens figma # mirror the tokens into Figma Variables
12
13
  ```
13
14
 
@@ -45,11 +46,90 @@ npx halftone-tokens figma # mirror the tokens into Figma Variables
45
46
  | `llms` | a tokens section for `llms.txt` |
46
47
  | `figma` | the input `halftone-tokens figma` pushes to Figma Variables |
47
48
 
49
+ ## Themes
50
+
51
+ A theme is a recipe (brand colour, neutral, radius, fonts, icon stroke, depth, density, border, typeset) that
52
+ `theme apply` expands into `tokens.json`, recording it so `theme show` can tell when tokens were edited by hand.
53
+
54
+ ```bash
55
+ npx halftone-tokens theme apply <theme> [--dry-run] [--force] # write it into tokens.json and rebuild
56
+ npx halftone-tokens theme show [--check] # the applied theme, and any hand edits since
57
+ npx halftone-tokens theme presets # the presets and their codes
58
+ npx halftone-tokens theme encode <theme> # the Halftone code for a theme
59
+ npx halftone-tokens theme decode <code> [--json] # what a code holds
60
+ npx halftone-tokens theme resolve [--json] # the code for tokens.json as it is (exit 1 if none or edited)
61
+ npx halftone-tokens theme shadcn <theme> [--out file] [--fonts-url url] # a shadcn registry:theme item
62
+ ```
63
+
64
+ `<theme>` is a Halftone code (`ht1-…`, `ht2-…`, `ht3-…`), a preset name, a recipe `.json`, a system
65
+ `.halftone.json` (see Systems), or a
66
+ [shadcn/ui](https://ui.shadcn.com/create) preset code (`b5owWMfJ8l`). A shadcn code becomes the nearest Halftone
67
+ recipe, and what it approximates or drops (shadcn-only greys, radius steps, fonts outside the list, icon
68
+ libraries, chart colours, menu options, styles) is listed on stderr. The other way, `theme shadcn` writes the
69
+ theme as a `registry:theme` item for `npx shadcn add`: colours for light and dark, radius, shadows, and its fonts
70
+ as `registry:font` dependencies (hosted at halftonedesignsystem.com/r/font, or `--fonts-url`).
71
+
72
+ From code: `decodeShadcnPreset`, `recipeFromShadcn`, `shadcnTheme` and `shadcnFont` in
73
+ `@halftone-ds/tokens/theme` (browser-safe). The preset codec is ported from shadcn/ui (MIT).
74
+
75
+ ## Systems
76
+
77
+ A **system** (`name.halftone.json`, what the Halftone Studio saves) is a recipe plus whatever was edited by hand
78
+ on top of it: a seed per colour role (brand, neutral, danger, success, warning, info) with a 50–950 ramp, steps
79
+ edited by hand, the role mapping for light and dark, text styles (h1–h4, lead, p, large, small, muted, code,
80
+ blockquote), any Google Fonts family, corners, shadows, borders, named extras, component edits (Button, Chip,
81
+ Badge, Input), and the brand (name, logos, icon background).
82
+
83
+ ```bash
84
+ npx halftone-tokens theme apply ./acme.halftone.json # writes tokens.json and records the system
85
+ npx halftone-tokens theme show # the system, its overrides, and any contrast warnings
86
+ npx halftone-tokens theme encode ./acme.halftone.json # its ht- code, when it has no overrides
87
+ ```
88
+
89
+ - `tokens.json` records the document under `$extensions`, without its brand images. A system with overrides has
90
+ no `ht-` code: a code only carries a recipe.
91
+ - Generated colours are fixed for contrast as a recipe's are. A colour chosen by hand (a mapped role, an edited
92
+ step) is never moved: a pairing it fails is a warning.
93
+ - A status ramp from a seed replaces the file's; applying a theme without one puts the curated ramp back.
94
+ - Edited text styles are written as `semantic.type.<style>.{size,leading,weight,tracking,font}`, corners as
95
+ `semantic.radius.<step>` (the build uses them as fixed px), extras under `extra.*` (exported, not drawn by
96
+ the kits).
97
+ - Every text style (`h1 h2 h3 h4 lead p large small muted code blockquote`), edited or not, is built as
98
+ `--type-<style>-{size,leading,weight,tracking}` (plus `-font` on Tailwind 4). nativewind3 adds the `text-`,
99
+ `leading-`, `tracking-` and `font-type-<style>` keys; shadcn-web adds `type-<style>` utilities; ts-theme adds
100
+ `TOKENS.textStyles`. With no edits they draw exactly what Tailwind's `text-*` classes did, with one exception:
101
+ tracking is em on every platform, so on native it scales with the style's size (NativeWind's own `tracking-*`
102
+ are fixed px there: `tracking-tight` is -0.25px). `themeVars` carries the styles to live previews
103
+ (`textStyleVars`, `themeTextStyles`).
104
+ - Every theme writes a component layer, `semantic.component.<button|chip|badge|input>`: a corner, heights and
105
+ padding, a text style and each variant's fill, label, border and pressed colours (references to semantic
106
+ colours). Both kits read it as `--button-*`, `--chip-*`, `--badge-*` and `--field-*` (`bg-button-primary-bg`,
107
+ `rounded-chip`, `h-button-md`, `text-type-button` in nativewind3; `type-button` and the same colours in
108
+ shadcn-web), so a Button looks the same in both. A system's `components` sets any of it (roles by name, a
109
+ Foundations corner or px). The build's contrast gate checks every variant's label on its fill.
110
+
111
+ From code (`@halftone-ds/tokens/system`, also in `/theme`, browser-safe): `normalizeSystem` / `validateSystem`,
112
+ `createSystem`, `migrate(recipe | code, brand)` and `systemToRecipe`, `generateSystem(system, tokens)` (the same
113
+ shape as `generateTheme`, plus `warnings`), `systemRamps`, `textStyles`, `systemRadii`, `systemCode` and
114
+ `applySystem`. The document's shape is described in `lib/system/schema.mjs`.
115
+
48
116
  ## Guarantees
49
117
 
50
- - **Contrast gate.** Every `X` / `X-foreground` pairing, body and muted text on every surface, each status
51
- colour used as text, and each status icon on its own tint must clear WCAG AA in both themes, or the build
52
- fails, names the pairing, prints the ratio, and writes nothing.
118
+ - **Contrast check.** Every `X` / `X-foreground` pairing, body and muted text on every surface, each status
119
+ colour used as text, and each status icon on its own tint is checked against WCAG AA in both themes. A
120
+ pairing that falls short is named with its ratio, and the generated DESIGN.md lists it. By default that is a
121
+ warning and the build carries on; with `--strict` (or `HALFTONE_STRICT=1`) the build fails and writes
122
+ nothing. The same goes for a tone's text on a tint the palette can't carry, when the config has `scan`.
123
+ - **APCA, for reading only.** `apcaContrast(text, background)` (from `@halftone-ds/tokens/theme` or
124
+ `@halftone-ds/tokens/lib/apca`) gives the APCA Lc of a pair, and `contrastReport(theme.resolved)` lists every
125
+ pairing the gate checks, in both themes, with its WCAG ratio, `pass`, and Lc. Lc is advisory: WCAG AA stays
126
+ the gate, and nothing fails on APCA.
127
+ - **Colour vision, as a warning.** `cvdWarnings(theme.resolved)` (from `@halftone-ds/tokens/theme` or
128
+ `@halftone-ds/tokens/lib/cvd`) simulates protanopia, deuteranopia and tritanopia (Machado 2009,
129
+ `simulateCvd`) and lists the status pairs (success / destructive and the rest) and adjacent chart colours
130
+ that end up closer than OKLab ΔE 0.05 under any of them, or under normal sight. `theme apply` prints them.
131
+ Nothing fails on it: the kit pairs every status with an icon or a label, and the warning says where colour
132
+ alone stops doing the work.
53
133
  - **Text on tints.** A status colour as text on its own tint (`text-success` on `bg-success/15`) loses
54
134
  contrast fast: 4.09:1 with a typical green. The build works out, per colour, the strongest tint that
55
135
  still clears AA in both themes, and writes it into `DESIGN.md` and the `llms` section
package/cli.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * halftone-tokens — turns tokens/tokens.json into everything an app reads.
4
4
  *
5
- * halftone-tokens build resolve, run the contrast gate, write every target in tokens.config.json
5
+ * halftone-tokens build resolve, check contrast, write every target in tokens.config.json
6
6
  * halftone-tokens check same, but write nothing; exit 1 if any generated file is stale (CI)
7
7
  * halftone-tokens figma mirror the tokens into Figma Variables (see push-figma.mjs)
8
8
  * halftone-tokens theme apply, show or list themes (see lib/theme-cli.mjs)
@@ -10,6 +10,9 @@
10
10
  *
11
11
  * --root <dir> app root (default: cwd); every path in the config is relative to it
12
12
  * --config <file> default: tokens/tokens.config.json
13
+ * --strict fail (exit 1) on contrast instead of warning; also HALFTONE_STRICT=1 in the environment.
14
+ * Every pairing below WCAG AA, and every tone's text on a tint the palette can't carry, is
15
+ * named either way; Halftone's own kits build strict, so they never ship below AA.
13
16
  *
14
17
  * tokens.config.json: { "source": "tokens/tokens.json", "targets": { "<target>": { …options } } }
15
18
  * Targets live in ./targets/<name>.mjs and return [{ path, contents }]. All outputs are GENERATED —
@@ -19,14 +22,14 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
19
22
  import { dirname, join, resolve } from 'node:path';
20
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
24
 
22
- import { checkContrast, AA, describeTintLimits, textOnTintLimits } from './lib/contrast.mjs';
25
+ import { checkContrast, AA, componentFailures, describeTintLimits, textOnTintLimits } from './lib/contrast.mjs';
23
26
  import { scanTintText } from './lib/scan.mjs';
24
27
  import { loadTokens } from './lib/resolve.mjs';
25
28
 
26
29
  const here = dirname(fileURLToPath(import.meta.url));
27
30
 
28
31
  /** Flags that take a value; every other --flag is a boolean. */
29
- const VALUE_FLAGS = new Set(['root', 'config']);
32
+ const VALUE_FLAGS = new Set(['root', 'config', 'out', 'fonts-url']);
30
33
 
31
34
  function parseArgs(argv) {
32
35
  const [command = 'build', ...rest] = argv;
@@ -68,38 +71,54 @@ export async function run(argv = process.argv.slice(2)) {
68
71
 
69
72
  if (command === 'theme') {
70
73
  const { themeCommand } = await import('./lib/theme-cli.mjs');
71
- const rebuild = ['build', '--root', root, ...(flags.config ? ['--config', flags.config] : [])];
74
+ const rebuild = ['build', '--root', root, ...(flags.config ? ['--config', flags.config] : []), ...(flags.strict ? ['--strict'] : [])];
72
75
  return themeCommand({ positional, flags, root, sourcePath: join(root, source), config, build: () => run(rebuild) });
73
76
  }
74
77
  const ctx = await loadTokens(join(root, source));
75
78
  ctx.root = root;
76
79
  ctx.header = `GENERATED by \`npm run tokens:build\` from ${source} — do not edit by hand.`;
77
80
 
78
- const failures = checkContrast(ctx.colors, { strict: true });
81
+ // Contrast is checked on every build. By default a problem is a warning (the palette is the designer's
82
+ // call); --strict or HALFTONE_STRICT=1 makes it an error, which is how Halftone's own CI builds the kits.
83
+ const strict = flags.strict === true || /^(1|true)$/i.test(process.env.HALFTONE_STRICT ?? '');
84
+ let warnings = 0;
85
+ const gate = (title, body) => {
86
+ console.error(`\n${title} ${strict ? 'FAILED' : 'WARNING'} — ${body}`);
87
+ if (strict) process.exit(1);
88
+ warnings++;
89
+ };
90
+
91
+ // `strict: true` here is the full set of pairings (muted and accent surfaces too), not the CLI flag.
92
+ const base = checkContrast(ctx.colors, { strict: true });
93
+ // The components' own pairings (a Button variant's label on its fill), once per colour pair.
94
+ const seen = new Set(base.map((line) => /^\s+(\w+) .*\((#\w+) on (#\w+)\)$/.exec(line)?.slice(1).join(' ')));
95
+ const failures = [...base, ...componentFailures(ctx.components, ctx.colors, seen)];
96
+ ctx.contrastFailures = failures;
79
97
  if (failures.length) {
80
- console.error(
81
- `\ntokens:build FAILED — ${failures.length} colour pairing(s) below WCAG AA (${AA}:1):\n\n${failures.join('\n')}\n\n` +
98
+ gate(
99
+ 'tokens:build',
100
+ `${failures.length} colour pairing(s) below WCAG AA (${AA}:1):\n\n${failures.join('\n')}\n\n` +
82
101
  'Darken the fill, or flip its -foreground between neutral.0 and neutral.950.\n' +
83
102
  'A brightened accent (usually the dark theme) needs dark text, not white.\n' +
84
103
  'Or let the theme generator fix it: `halftone-tokens theme apply <code|preset>` keeps the hue and adjusts the lightness.\n'
85
104
  );
86
- process.exit(1);
87
105
  }
88
106
 
89
- // The palette passes; now the source: a tone's text on its own tint, beyond what this palette allows.
107
+ // Then the source: a tone's text on its own tint, beyond what this palette allows.
90
108
  if (config.scan?.length) {
91
109
  const limits = textOnTintLimits(ctx.colors);
92
110
  const problems = scanTintText(root, config.scan, limits);
93
111
  if (problems.length) {
94
- console.error(
95
- `\nhalftone-tokens: ${problems.length} class string(s) put a tone's text on its own tint below WCAG AA (${AA}:1):\n\n${problems.join('\n')}\n\n` +
112
+ gate(
113
+ 'halftone-tokens',
114
+ `${problems.length} class string(s) put a tone's text on its own tint below WCAG AA (${AA}:1):\n\n${problems.join('\n')}\n\n` +
96
115
  `This palette allows: ${describeTintLimits(limits)}.\n` +
97
116
  'Use text-foreground on the tint (let the tint, an icon or a dot carry the colour), or a lighter tint.\n' +
98
117
  'An element that holds only an icon may keep its tone: add a `halftone-tokens-ignore tint-text` comment.\n'
99
118
  );
100
- process.exit(1);
101
119
  }
102
120
  }
121
+ const contrast = warnings ? `${warnings} contrast warning(s), run with --strict to fail` : 'contrast AA ✓';
103
122
 
104
123
  const files = [];
105
124
  for (const [name, options] of Object.entries(config.targets ?? {})) {
@@ -116,7 +135,7 @@ export async function run(argv = process.argv.slice(2)) {
116
135
  console.error(`halftone-tokens check: ${stale.length} generated file(s) out of date — run \`npm run tokens:build\`:\n${stale.map((f) => ` ${f.path}`).join('\n')}`);
117
136
  process.exit(1);
118
137
  }
119
- console.log(`halftone-tokens check: ${files.length} files up to date · contrast AA ✓`);
138
+ console.log(`halftone-tokens check: ${files.length} files up to date · ${contrast}`);
120
139
  return;
121
140
  }
122
141
 
@@ -125,7 +144,7 @@ export async function run(argv = process.argv.slice(2)) {
125
144
  mkdirSync(dirname(path), { recursive: true });
126
145
  writeFileSync(path, f.contents);
127
146
  }
128
- console.log(`tokens:build → ${files.map((f) => f.path).join(', ')} · contrast AA ✓`);
147
+ console.log(`tokens:build → ${files.map((f) => f.path).join(', ')} · ${contrast}`);
129
148
  }
130
149
 
131
150
  // npm runs bins through a symlink in node_modules/.bin, so compare real paths.
package/lib/apca.mjs ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * APCA (the Accessible Perceptual Contrast Algorithm, APCA-W3 0.0.98G-4g), the candidate contrast method
3
+ * for WCAG 3. ADVISORY ONLY: the build gates on WCAG 2.1 AA (contrast.mjs) and nothing here can fail it.
4
+ * The docs studio shows Lc beside the WCAG ratio so a re-brand can be read both ways.
5
+ *
6
+ * Unlike the WCAG ratio, APCA is not symmetric: the text colour comes first. Lc is positive for dark text
7
+ * on a light background and negative for light on dark; compare |Lc| against the guidance (roughly 75 for
8
+ * body text, 60 for other content text, 45 for large text, 30 for icons and other non-text).
9
+ */
10
+
11
+ // The 0.0.98G-4g constants, as published in apca-w3.
12
+ const TRC = 2.4;
13
+ const [R, G, B] = [0.2126729, 0.7151522, 0.072175];
14
+ const NORM_BG = 0.56;
15
+ const NORM_TXT = 0.57;
16
+ const REV_TXT = 0.62;
17
+ const REV_BG = 0.65;
18
+ const BLK_THRS = 0.022;
19
+ const BLK_CLMP = 1.414;
20
+ const SCALE = 1.14;
21
+ const LO_OFFSET = 0.027;
22
+ const LO_CLIP = 0.1;
23
+ const DELTA_Y_MIN = 0.0005;
24
+
25
+ function parseHex(hex) {
26
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
27
+ if (!m) throw new Error(`Expected a 6-digit hex colour, got "${hex}"`);
28
+ const n = parseInt(m[1], 16);
29
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
30
+ }
31
+
32
+ /** APCA's screen luminance Y: a simple 2.4 power curve, not the sRGB piecewise one WCAG uses. */
33
+ export function apcaLuminance(hex) {
34
+ const [r, g, b] = parseHex(hex).map((c) => (c / 255) ** TRC);
35
+ return R * r + G * g + B * b;
36
+ }
37
+
38
+ /** Soft-clamp near-black, as APCA does, so very dark pairs don't report inflated contrast. */
39
+ const clampBlack = (y) => (y > BLK_THRS ? y : y + (BLK_THRS - y) ** BLK_CLMP);
40
+
41
+ /** Lc (lightness contrast) of `text` on `background`, about -108 to 106. 0 means no usable contrast. */
42
+ export function apcaContrast(text, background) {
43
+ const yTxt = clampBlack(apcaLuminance(text));
44
+ const yBg = clampBlack(apcaLuminance(background));
45
+ if (Math.abs(yBg - yTxt) < DELTA_Y_MIN) return 0;
46
+
47
+ if (yBg > yTxt) {
48
+ // Dark text on a light background.
49
+ const sapc = (yBg ** NORM_BG - yTxt ** NORM_TXT) * SCALE;
50
+ return sapc < LO_CLIP ? 0 : (sapc - LO_OFFSET) * 100;
51
+ }
52
+ // Light text on a dark background.
53
+ const sapc = (yBg ** REV_BG - yTxt ** REV_TXT) * SCALE;
54
+ return sapc > -LO_CLIP ? 0 : (sapc + LO_OFFSET) * 100;
55
+ }
package/lib/color.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  * hexToHslTriplet NativeWind 4 / shadcn v3 CSS variables (`221.2 83.2% 53.3%`)
4
4
  * hexToOklch Tailwind 4 / shadcn web CSS variables (`oklch(0.546 0.2152 262.88)`)
5
5
  * contrast WCAG 2.1 ratio, for the gate in contrast.mjs
6
+ * deltaEOK OKLab distance, for the colour-vision check in cvd.mjs
6
7
  */
7
8
 
8
9
  function parseHex(hex) {
@@ -37,15 +38,34 @@ export function hexToHslTriplet(hex) {
37
38
  const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
38
39
  const fromLinear = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
39
40
 
40
- /** Hex → [L 0–1, C, H degrees], unrounded. */
41
- export function hexToOklchParts(hex) {
42
- const [r, g, b] = parseHex(hex).map((c) => toLinear(c / 255));
41
+ /** Hex → linear-light sRGB [r, g, b], 0–1. */
42
+ export const hexToLinearRgb = (hex) => parseHex(hex).map((c) => toLinear(c / 255));
43
+
44
+ /** Linear-light sRGB → 8-bit hex (clamped). */
45
+ export const linearRgbToHex = (rgb) => toHex(rgb.map((c) => Math.round(Math.min(1, Math.max(0, fromLinear(c))) * 255)));
46
+
47
+ /** Hex → OKLab [L 0–1, a, b], unrounded. */
48
+ export function hexToOklab(hex) {
49
+ const [r, g, b] = hexToLinearRgb(hex);
43
50
  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
44
51
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
45
52
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
46
- const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
47
- const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
48
- const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
53
+ return [
54
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
55
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
56
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
57
+ ];
58
+ }
59
+
60
+ /** ΔE in OKLab: the straight-line distance, L on its 0–1 scale (about 0.02 is a just-noticeable difference). */
61
+ export function deltaEOK(a, b) {
62
+ const [p, q] = [hexToOklab(a), hexToOklab(b)];
63
+ return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]);
64
+ }
65
+
66
+ /** Hex → [L 0–1, C, H degrees], unrounded. */
67
+ export function hexToOklchParts(hex) {
68
+ const [L, A, B] = hexToOklab(hex);
49
69
  const C = Math.hypot(A, B);
50
70
  const H = C < 1e-4 ? 0 : ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
51
71
  return [L, C < 1e-4 ? 0 : C, H];
@@ -67,7 +87,7 @@ function oklchToLinearRgb([L, C, H]) {
67
87
 
68
88
  /** [L, C, H] → 8-bit sRGB hex (clamped). */
69
89
  export function oklchPartsToHex(parts) {
70
- return toHex(oklchToLinearRgb(parts).map((c) => Math.round(Math.min(1, Math.max(0, fromLinear(c))) * 255)));
90
+ return linearRgbToHex(oklchToLinearRgb(parts));
71
91
  }
72
92
 
73
93
  /** Whether [L, C, H] lies inside sRGB (no channel clipped). */
package/lib/contrast.mjs CHANGED
@@ -3,7 +3,9 @@
3
3
  * colour will happily fail WCAG without anyone noticing. Every pairing the kit actually renders is
4
4
  * checked here, and a failure stops the build.
5
5
  */
6
+ import { apcaContrast } from './apca.mjs';
6
7
  import { blend, contrast } from './color.mjs';
8
+ import { componentPairs } from './theme/components.mjs';
7
9
 
8
10
  export const AA = 4.5; // WCAG 2.1 AA, normal text. Kit labels are 16px — not "large text".
9
11
  export const AA_NON_TEXT = 3; // WCAG 1.4.11, icons and other meaningful non-text.
@@ -33,8 +35,10 @@ export function contrastPairs(hex, mode, { strict = false, fg: only } = {}) {
33
35
  if (!only || fgName === only) out.push({ mode, label, fgName, bgName, fg, bg, threshold, ratio: contrast(fg, bg) });
34
36
  };
35
37
 
36
- for (const name of ['primary', 'secondary', 'destructive', 'success', 'warning', 'info', 'accent', 'card', 'popover', 'muted', 'sidebar', 'sidebar-primary', 'sidebar-accent']) {
38
+ for (const name of ['primary', 'button', 'secondary', 'destructive', 'success', 'warning', 'info', 'accent', 'card', 'popover', 'muted', 'sidebar', 'sidebar-primary', 'sidebar-accent']) {
37
39
  const fg = `${name}-foreground`;
40
+ // A solid button is the primary pairing again: counted once.
41
+ if (name === 'button' && hex.button === hex.primary && hex[fg] === hex['primary-foreground']) continue;
38
42
  if (hex[name] && hex[fg]) add(`${fg} on ${name}`, fg, name, hex[fg], hex[name], AA);
39
43
  }
40
44
  for (const surface of ['background', 'card', 'muted']) {
@@ -50,6 +54,14 @@ export function contrastPairs(hex, mode, { strict = false, fg: only } = {}) {
50
54
  }
51
55
  }
52
56
 
57
+ // A field whose line is its only boundary (filled, underline: field-edge isn't the input colour) needs that
58
+ // line at 3:1 (WCAG 1.4.11) on the field and on the surfaces it sits on.
59
+ if (hex['field-edge'] && hex.input && hex['field-edge'] !== hex.input) {
60
+ for (const surface of ['field', 'background', 'card']) {
61
+ if (hex[surface]) add(`field-edge on ${surface}`, 'field-edge', surface, hex['field-edge'], hex[surface], AA_NON_TEXT);
62
+ }
63
+ }
64
+
53
65
  // A tone's icon on that tone's own tint — Alert, IconCircle, SwipeToConfirm.
54
66
  for (const tone of TONES) {
55
67
  if (!hex[tone] || !hex.background) continue;
@@ -60,6 +72,17 @@ export function contrastPairs(hex, mode, { strict = false, fg: only } = {}) {
60
72
  return out;
61
73
  }
62
74
 
75
+ /**
76
+ * Every pairing the build checks (the strict set), in both modes, for a table people read: each row has
77
+ * `pass` against its WCAG threshold, and `apca`, the APCA Lc of the same pair. APCA is advisory and never
78
+ * decides `pass`. `hex` is { light: { name: '#rrggbb' }, dark: { … } }, as a generated theme's `resolved`.
79
+ */
80
+ export function contrastReport(hex, { strict = true } = {}) {
81
+ return ['light', 'dark'].flatMap((mode) =>
82
+ contrastPairs(hex[mode] ?? {}, mode, { strict }).map((r) => ({ ...r, pass: r.ratio >= r.threshold, apca: apcaContrast(r.fg, r.bg) })),
83
+ );
84
+ }
85
+
63
86
  /** The failing pairings as report lines. */
64
87
  export function contrastFailures(hex, mode, options) {
65
88
  return contrastPairs(hex, mode, options)
@@ -103,3 +126,35 @@ export function checkContrast(colors, options) {
103
126
  const of = (mode) => Object.fromEntries(colors.map((c) => [c.name, c[mode]]));
104
127
  return [...contrastFailures(of('light'), 'light', options), ...contrastFailures(of('dark'), 'dark', options)];
105
128
  }
129
+
130
+ /**
131
+ * The component layer's label-on-fill pairings (lib/theme/components.mjs), in both modes, as rows like contrastReport's:
132
+ * { mode, label, fgName, bgName, fg, bg, threshold, ratio, component, variant }. `bgName` is the fill's role.
133
+ */
134
+ export function componentContrast(components, background) {
135
+ return ['light', 'dark'].flatMap((mode) =>
136
+ componentPairs(components, mode, background[mode], blend).map((p) => ({
137
+ mode,
138
+ label: p.label,
139
+ fgName: p.fgRole,
140
+ bgName: p.bgRole,
141
+ fg: p.fg,
142
+ bg: p.bg,
143
+ threshold: AA,
144
+ ratio: contrast(p.fg, p.bg),
145
+ component: p.component,
146
+ variant: p.variant,
147
+ }))
148
+ );
149
+ }
150
+
151
+ /** The failing component pairings as report lines, leaving out any colour pair `seen` already reports ('mode fg bg'). */
152
+ export function componentFailures(components, colors, seen = new Set()) {
153
+ const background = Object.fromEntries(['light', 'dark'].map((mode) => [mode, colors.find((c) => c.name === 'background')?.[mode] ?? '#ffffff']));
154
+ const lines = new Set();
155
+ for (const r of componentContrast(components, background)) {
156
+ if (r.ratio >= r.threshold || seen.has(`${r.mode} ${r.fg} ${r.bg}`)) continue;
157
+ lines.add(` ${r.mode.padEnd(5)} ${r.label.padEnd(36)} ${r.ratio.toFixed(2)} / ${r.threshold} (${r.fg} on ${r.bg})`);
158
+ }
159
+ return [...lines];
160
+ }
package/lib/cvd.mjs ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Colour-vision check. The contrast gate proves every tone reads against its surface; it says nothing
3
+ * about whether two tones can be told apart by someone who cannot see red from green. This simulates the
4
+ * three dichromacies and measures what is left of the difference between tones that carry meaning:
5
+ *
6
+ * simulateCvd(hex, type) what a protanope, deuteranope or tritanope sees: Machado, Oliveira & Fernandes
7
+ * 2009 at severity 1.0, applied in linear-light sRGB. One model for all three
8
+ * (Viénot 1999 covers only protan and deutan; for reds, greens and ambers the two
9
+ * land within about 0.03 ΔE of each other, Machado moves saturated blues more)
10
+ * cvdWarnings(resolved) pairs that fall below MIN_DELTA_E under any vision, per mode
11
+ *
12
+ * A warning, never a failure: a red/green pair can still be told apart by its icon, label or position,
13
+ * and the kit always gives a status one of those. The check says when colour alone stops doing the work.
14
+ * Pure ESM, no Node APIs: the docs studio runs it in the browser.
15
+ */
16
+ import { deltaEOK, hexToLinearRgb, linearRgbToHex } from './color.mjs';
17
+
18
+ export const CVD_TYPES = ['protanopia', 'deuteranopia', 'tritanopia'];
19
+
20
+ /** Machado et al. 2009, severity 1.0, rows → linear R, G, B. */
21
+ const MATRICES = {
22
+ protanopia: [
23
+ [0.152286, 1.052583, -0.204868],
24
+ [0.114503, 0.786281, 0.099216],
25
+ [-0.003882, -0.048116, 1.051998],
26
+ ],
27
+ deuteranopia: [
28
+ [0.367322, 0.860646, -0.227968],
29
+ [0.280085, 0.672501, 0.047413],
30
+ [-0.01182, 0.04294, 0.968881],
31
+ ],
32
+ tritanopia: [
33
+ [1.255528, -0.076749, -0.178779],
34
+ [-0.078411, 0.930809, 0.147602],
35
+ [0.004733, 0.691367, 0.3039],
36
+ ],
37
+ };
38
+
39
+ /** `hex` as a dichromat sees it. `type` 'none' (or anything falsy) returns it unchanged. */
40
+ export function simulateCvd(hex, type) {
41
+ if (!type || type === 'none') return hex.toLowerCase();
42
+ const m = MATRICES[type];
43
+ if (!m) throw new Error(`simulateCvd: unknown vision "${type}", expected one of ${CVD_TYPES.join(', ')}`);
44
+ const rgb = hexToLinearRgb(hex);
45
+ return linearRgbToHex(m.map((row) => row[0] * rgb[0] + row[1] * rgb[1] + row[2] * rgb[2]));
46
+ }
47
+
48
+ /**
49
+ * The smallest OKLab distance two tones that mean different things may keep. About 0.02 is one
50
+ * just-noticeable difference for large patches side by side; a status dot or a 2px chart line is small and
51
+ * seldom right next to its partner, so this asks for a couple and a half of them.
52
+ */
53
+ export const MIN_DELTA_E = 0.05;
54
+
55
+ /** The status tones are each other's alternatives; a chart's adjacent series sit side by side. */
56
+ export const DISTINCT_PAIRS = [
57
+ ['success', 'destructive'],
58
+ ['success', 'warning'],
59
+ ['warning', 'destructive'],
60
+ ['info', 'success'],
61
+ ['info', 'warning'],
62
+ ['info', 'destructive'],
63
+ ['chart-1', 'chart-2'],
64
+ ['chart-2', 'chart-3'],
65
+ ['chart-3', 'chart-4'],
66
+ ['chart-4', 'chart-5'],
67
+ ];
68
+
69
+ /** The pairs as one vision sees them in one mode: `hex` is { name: '#rrggbb' }. */
70
+ export function pairDistances(hex, type, pairs = DISTINCT_PAIRS) {
71
+ return pairs
72
+ .filter(([a, b]) => hex[a] && hex[b])
73
+ .map(([a, b]) => {
74
+ const [sa, sb] = [simulateCvd(hex[a], type), simulateCvd(hex[b], type)];
75
+ return { a, b, vision: type || 'none', fg: sa, bg: sb, deltaE: deltaEOK(sa, sb) };
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Every pair, mode and vision (normal sight included) below `threshold`: `resolved` is
81
+ * { light: { name: hex }, dark: { … } }, the shape `generateTheme` returns as `theme.resolved`.
82
+ * A pair that collides under several visions is reported once per vision, worst first.
83
+ */
84
+ export function cvdWarnings(resolved, { pairs = DISTINCT_PAIRS, threshold = MIN_DELTA_E } = {}) {
85
+ const out = [];
86
+ for (const mode of ['light', 'dark']) {
87
+ if (!resolved[mode]) continue;
88
+ for (const type of ['none', ...CVD_TYPES]) {
89
+ for (const d of pairDistances(resolved[mode], type, pairs)) if (d.deltaE < threshold) out.push({ mode, ...d });
90
+ }
91
+ }
92
+ return out.sort((x, y) => x.deltaE - y.deltaE);
93
+ }
94
+
95
+ /** The warnings as report lines, like contrastFailures'. */
96
+ export const describeCvdWarnings = (warnings) =>
97
+ warnings.map((w) => ` ${w.mode.padEnd(5)} ${`${w.a} / ${w.b}`.padEnd(24)} ${w.vision.padEnd(12)} ΔE ${w.deltaE.toFixed(3)} (${w.fg} vs ${w.bg})`);