@halftone-ds/tokens 1.1.0 → 1.2.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,48 @@ 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`, or a
65
+ [shadcn/ui](https://ui.shadcn.com/create) preset code (`b5owWMfJ8l`). A shadcn code becomes the nearest Halftone
66
+ recipe, and what it approximates or drops (shadcn-only greys, radius steps, fonts outside the list, icon
67
+ libraries, chart colours, menu options, styles) is listed on stderr. The other way, `theme shadcn` writes the
68
+ theme as a `registry:theme` item for `npx shadcn add`: colours for light and dark, radius, shadows, and its fonts
69
+ as `registry:font` dependencies (hosted at halftonedesignsystem.com/r/font, or `--fonts-url`).
70
+
71
+ From code: `decodeShadcnPreset`, `recipeFromShadcn`, `shadcnTheme` and `shadcnFont` in
72
+ `@halftone-ds/tokens/theme` (browser-safe). The preset codec is ported from shadcn/ui (MIT).
73
+
48
74
  ## Guarantees
49
75
 
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.
76
+ - **Contrast check.** Every `X` / `X-foreground` pairing, body and muted text on every surface, each status
77
+ colour used as text, and each status icon on its own tint is checked against WCAG AA in both themes. A
78
+ pairing that falls short is named with its ratio, and the generated DESIGN.md lists it. By default that is a
79
+ warning and the build carries on; with `--strict` (or `HALFTONE_STRICT=1`) the build fails and writes
80
+ nothing. The same goes for a tone's text on a tint the palette can't carry, when the config has `scan`.
81
+ - **APCA, for reading only.** `apcaContrast(text, background)` (from `@halftone-ds/tokens/theme` or
82
+ `@halftone-ds/tokens/lib/apca`) gives the APCA Lc of a pair, and `contrastReport(theme.resolved)` lists every
83
+ pairing the gate checks, in both themes, with its WCAG ratio, `pass`, and Lc. Lc is advisory: WCAG AA stays
84
+ the gate, and nothing fails on APCA.
85
+ - **Colour vision, as a warning.** `cvdWarnings(theme.resolved)` (from `@halftone-ds/tokens/theme` or
86
+ `@halftone-ds/tokens/lib/cvd`) simulates protanopia, deuteranopia and tritanopia (Machado 2009,
87
+ `simulateCvd`) and lists the status pairs (success / destructive and the rest) and adjacent chart colours
88
+ that end up closer than OKLab ΔE 0.05 under any of them, or under normal sight. `theme apply` prints them.
89
+ Nothing fails on it: the kit pairs every status with an icon or a label, and the warning says where colour
90
+ alone stops doing the work.
53
91
  - **Text on tints.** A status colour as text on its own tint (`text-success` on `bg-success/15`) loses
54
92
  contrast fast: 4.09:1 with a typical green. The build works out, per colour, the strongest tint that
55
93
  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 —
@@ -26,7 +29,7 @@ import { loadTokens } from './lib/resolve.mjs';
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,51 @@ 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
 
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.
78
92
  const failures = checkContrast(ctx.colors, { strict: true });
93
+ ctx.contrastFailures = failures;
79
94
  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` +
95
+ gate(
96
+ 'tokens:build',
97
+ `${failures.length} colour pairing(s) below WCAG AA (${AA}:1):\n\n${failures.join('\n')}\n\n` +
82
98
  'Darken the fill, or flip its -foreground between neutral.0 and neutral.950.\n' +
83
99
  'A brightened accent (usually the dark theme) needs dark text, not white.\n' +
84
100
  'Or let the theme generator fix it: `halftone-tokens theme apply <code|preset>` keeps the hue and adjusts the lightness.\n'
85
101
  );
86
- process.exit(1);
87
102
  }
88
103
 
89
- // The palette passes; now the source: a tone's text on its own tint, beyond what this palette allows.
104
+ // Then the source: a tone's text on its own tint, beyond what this palette allows.
90
105
  if (config.scan?.length) {
91
106
  const limits = textOnTintLimits(ctx.colors);
92
107
  const problems = scanTintText(root, config.scan, limits);
93
108
  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` +
109
+ gate(
110
+ 'halftone-tokens',
111
+ `${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
112
  `This palette allows: ${describeTintLimits(limits)}.\n` +
97
113
  'Use text-foreground on the tint (let the tint, an icon or a dot carry the colour), or a lighter tint.\n' +
98
114
  'An element that holds only an icon may keep its tone: add a `halftone-tokens-ignore tint-text` comment.\n'
99
115
  );
100
- process.exit(1);
101
116
  }
102
117
  }
118
+ const contrast = warnings ? `${warnings} contrast warning(s), run with --strict to fail` : 'contrast AA ✓';
103
119
 
104
120
  const files = [];
105
121
  for (const [name, options] of Object.entries(config.targets ?? {})) {
@@ -116,7 +132,7 @@ export async function run(argv = process.argv.slice(2)) {
116
132
  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
133
  process.exit(1);
118
134
  }
119
- console.log(`halftone-tokens check: ${files.length} files up to date · contrast AA ✓`);
135
+ console.log(`halftone-tokens check: ${files.length} files up to date · ${contrast}`);
120
136
  return;
121
137
  }
122
138
 
@@ -125,7 +141,7 @@ export async function run(argv = process.argv.slice(2)) {
125
141
  mkdirSync(dirname(path), { recursive: true });
126
142
  writeFileSync(path, f.contents);
127
143
  }
128
- console.log(`tokens:build → ${files.map((f) => f.path).join(', ')} · contrast AA ✓`);
144
+ console.log(`tokens:build → ${files.map((f) => f.path).join(', ')} · ${contrast}`);
129
145
  }
130
146
 
131
147
  // 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,6 +3,7 @@
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';
7
8
 
8
9
  export const AA = 4.5; // WCAG 2.1 AA, normal text. Kit labels are 16px — not "large text".
@@ -33,8 +34,10 @@ export function contrastPairs(hex, mode, { strict = false, fg: only } = {}) {
33
34
  if (!only || fgName === only) out.push({ mode, label, fgName, bgName, fg, bg, threshold, ratio: contrast(fg, bg) });
34
35
  };
35
36
 
36
- for (const name of ['primary', 'secondary', 'destructive', 'success', 'warning', 'info', 'accent', 'card', 'popover', 'muted', 'sidebar', 'sidebar-primary', 'sidebar-accent']) {
37
+ for (const name of ['primary', 'button', 'secondary', 'destructive', 'success', 'warning', 'info', 'accent', 'card', 'popover', 'muted', 'sidebar', 'sidebar-primary', 'sidebar-accent']) {
37
38
  const fg = `${name}-foreground`;
39
+ // A solid button is the primary pairing again: counted once.
40
+ if (name === 'button' && hex.button === hex.primary && hex[fg] === hex['primary-foreground']) continue;
38
41
  if (hex[name] && hex[fg]) add(`${fg} on ${name}`, fg, name, hex[fg], hex[name], AA);
39
42
  }
40
43
  for (const surface of ['background', 'card', 'muted']) {
@@ -50,6 +53,14 @@ export function contrastPairs(hex, mode, { strict = false, fg: only } = {}) {
50
53
  }
51
54
  }
52
55
 
56
+ // A field whose line is its only boundary (filled, underline: field-edge isn't the input colour) needs that
57
+ // line at 3:1 (WCAG 1.4.11) on the field and on the surfaces it sits on.
58
+ if (hex['field-edge'] && hex.input && hex['field-edge'] !== hex.input) {
59
+ for (const surface of ['field', 'background', 'card']) {
60
+ if (hex[surface]) add(`field-edge on ${surface}`, 'field-edge', surface, hex['field-edge'], hex[surface], AA_NON_TEXT);
61
+ }
62
+ }
63
+
53
64
  // A tone's icon on that tone's own tint — Alert, IconCircle, SwipeToConfirm.
54
65
  for (const tone of TONES) {
55
66
  if (!hex[tone] || !hex.background) continue;
@@ -60,6 +71,17 @@ export function contrastPairs(hex, mode, { strict = false, fg: only } = {}) {
60
71
  return out;
61
72
  }
62
73
 
74
+ /**
75
+ * Every pairing the build checks (the strict set), in both modes, for a table people read: each row has
76
+ * `pass` against its WCAG threshold, and `apca`, the APCA Lc of the same pair. APCA is advisory and never
77
+ * decides `pass`. `hex` is { light: { name: '#rrggbb' }, dark: { … } }, as a generated theme's `resolved`.
78
+ */
79
+ export function contrastReport(hex, { strict = true } = {}) {
80
+ return ['light', 'dark'].flatMap((mode) =>
81
+ contrastPairs(hex[mode] ?? {}, mode, { strict }).map((r) => ({ ...r, pass: r.ratio >= r.threshold, apca: apcaContrast(r.fg, r.bg) })),
82
+ );
83
+ }
84
+
63
85
  /** The failing pairings as report lines. */
64
86
  export function contrastFailures(hex, mode, options) {
65
87
  return contrastPairs(hex, mode, options)
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})`);
package/lib/resolve.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import StyleDictionary from 'style-dictionary';
7
7
  import { readFileSync } from 'node:fs';
8
- import { DENSITY, DEPTH, shadowCss, TYPE, typeScale } from './theme/recipe.mjs';
8
+ import { BUTTON, DEFAULT_RECIPE, DENSITY, DEPTH, FIELD, fieldWidth, shadowCss, TYPE, typeScale } from './theme/recipe.mjs';
9
9
 
10
10
  export const MODES = ['light', 'dark'];
11
11
 
@@ -68,6 +68,9 @@ export function radiusCalc(scale) {
68
68
  * (a token file from before themes gets the kit's defaults)
69
69
  * type { size, scale, leading, leadingFactor, flow, measure } (semantic.type; defaults
70
70
  * for a token file from before the typeset)
71
+ * shape.button/.field the styles' numbers (semantic.button / .field), radii in px; a token file from
72
+ * before the styles gets solid buttons and outline fields, and colours to match
73
+ * styles { button, field }: the style names, from the recorded theme (or the defaults)
71
74
  */
72
75
  export async function loadTokens(sourcePath) {
73
76
  const source = JSON.parse(readFileSync(sourcePath, 'utf8'));
@@ -84,11 +87,34 @@ export async function loadTokens(sourcePath) {
84
87
  steps: radiusScales(prim),
85
88
  };
86
89
  const find = (list, name) => list.find((t) => cssName(t) === name)?.$value;
90
+ // Style colours a token file from before the styles doesn't have: what the components drew before.
91
+ const byName = Object.fromEntries(colors.map((c) => [c.name, c]));
92
+ const STYLE_FALLBACK = { button: 'primary', 'button-foreground': 'primary-foreground', 'button-edge': 'primary', field: 'input', 'field-edge': 'input' };
93
+ for (const [name, from] of Object.entries(STYLE_FALLBACK)) if (!byName[name] && byName[from]) colors.push({ ...byName[from], name });
87
94
  const shadows = (list, mode) =>
88
95
  Object.fromEntries(['1', '2', '3'].map((level) => [level, shadowCss(find(list, `shadow-${level}`) ?? DEPTH.soft[level][mode])]));
96
+ const borderWidth = find(semLight, 'border-width') ?? 1;
97
+ const md = radius.steps.find((s) => s.name === 'md')?.scale ?? 0.8;
98
+ const solid = BUTTON.solid;
99
+ const outline = FIELD.outline;
100
+ const fieldRound = find(semLight, 'field-round') ?? outline.round;
101
+ const fieldRoundBottom = find(semLight, 'field-round-bottom') ?? outline.roundBottom;
89
102
  const shape = {
90
103
  control: find(semLight, 'radius-control') ?? 9999,
91
- borderWidth: find(semLight, 'border-width') ?? 1,
104
+ borderWidth,
105
+ button: {
106
+ alpha: find(semLight, 'button-alpha') ?? solid.alpha,
107
+ border: find(semLight, 'button-border-width') ?? solid.border,
108
+ edge: find(semLight, 'button-edge-width') ?? solid.edge,
109
+ },
110
+ field: {
111
+ alpha: { light: find(semLight, 'field-alpha') ?? outline.alpha.light, dark: find(semDark, 'field-alpha') ?? outline.alpha.dark },
112
+ border: find(semLight, 'field-border-width') ?? fieldWidth(outline.border, borderWidth),
113
+ edge: find(semLight, 'field-edge-width') ?? fieldWidth(outline.edge, borderWidth),
114
+ inset: find(semLight, 'field-inset') ?? outline.inset,
115
+ radius: Number((radius.base * md * fieldRound).toFixed(4)),
116
+ radiusBottom: Number((radius.base * md * fieldRoundBottom).toFixed(4)),
117
+ },
92
118
  stroke: find(semLight, 'icon-stroke') ?? 2,
93
119
  shadows: { light: shadows(semLight, 'light'), dark: shadows(semDark, 'dark') },
94
120
  density: {
@@ -97,7 +123,9 @@ export async function loadTokens(sourcePath) {
97
123
  },
98
124
  };
99
125
  const type = typeScale(Object.fromEntries(Object.entries(TYPE).map(([key, value]) => [key, find(semLight, `type-${key}`) ?? value])));
100
- return { source, semLight, semDark, prim, colors, radius, shape, type };
126
+ const recorded = source.$extensions?.['com.halftonedesignsystem.theme']?.recipe ?? {};
127
+ const styles = { button: recorded.button ?? DEFAULT_RECIPE.button, field: recorded.field ?? DEFAULT_RECIPE.field };
128
+ return { source, semLight, semDark, prim, colors, radius, shape, type, styles };
101
129
  }
102
130
 
103
131
  /** Primitive tokens under a path prefix, e.g. group(prim, ['font', 'size']) → [{ key: 'xs', value: 12 }, …]. */
@@ -30,10 +30,12 @@ function ownedValues(theme) {
30
30
  out['primitive.font.family.body'] = theme.fonts.body;
31
31
  out['primitive.font.family.mono'] = theme.fonts.mono;
32
32
  for (const [key, value] of Object.entries(theme.semantic.type)) out[`semantic.type.${key}`] = value;
33
+ for (const [key, value] of Object.entries(theme.semantic.button)) out[`semantic.button.${key}`] = value;
34
+ for (const [key, value] of Object.entries(theme.semantic.field)) out[`semantic.field.${key}`] = value;
33
35
  return out;
34
36
  }
35
37
 
36
- const TYPES = { radius: 'number', border: 'number', icon: 'number', density: 'number', type: 'number', shadow: 'shadow', font: 'fontFamily', color: 'color' };
38
+ const TYPES = { radius: 'number', border: 'number', icon: 'number', density: 'number', type: 'number', button: 'number', field: 'number', shadow: 'shadow', font: 'fontFamily', color: 'color' };
37
39
  const DESCRIPTIONS = {
38
40
  'semantic.radius.control': 'Corner radius of mobile buttons and chips: rounded-control. 9999 = pills; otherwise the base radius.',
39
41
  'semantic.border.width': 'Width of `border` (px). border-2 and border-hairline stay explicit.',
@@ -53,6 +55,20 @@ const DESCRIPTIONS = {
53
55
  'semantic.type.leading': "Typeset: line height of running text (× size); the text sizes' line heights scale by leading / 1.75.",
54
56
  'semantic.type.flow': 'Typeset: space between blocks of long-form text (Prose), em.',
55
57
  'semantic.type.measure': 'Typeset: the longest line of long-form text (Prose), characters.',
58
+ 'semantic.color.button': 'The primary button: bg-button (its alpha is button.alpha). Follows the theme\'s button style.',
59
+ 'semantic.color.button-foreground': 'Text and icons on the primary button: text-button-foreground.',
60
+ 'semantic.color.button-edge': 'The primary button\'s border and bottom edge: border-button-edge.',
61
+ 'semantic.color.field': 'Text fields: bg-field (its alpha is field.alpha). Follows the theme\'s field style.',
62
+ 'semantic.color.field-edge': 'Text fields\' border or bottom line: border-field-edge.',
63
+ 'semantic.button.alpha': 'Opacity of the primary button\'s fill (0 = an outline button).',
64
+ 'semantic.button.border-width': 'Primary button border, every side (px).',
65
+ 'semantic.button.edge-width': 'Primary button bottom edge (px); raised buttons have a thicker, darker one.',
66
+ 'semantic.field.alpha': 'Opacity of a text field\'s fill, per mode.',
67
+ 'semantic.field.border-width': 'Text field border, top and sides (px).',
68
+ 'semantic.field.edge-width': 'Text field bottom border (px).',
69
+ 'semantic.field.round': '1 = text fields take the md radius on top, 0 = square.',
70
+ 'semantic.field.round-bottom': '1 = text fields take the md radius at the bottom, 0 = square.',
71
+ 'semantic.field.inset': '1 = text fields have inner padding, 0 = flush (underline fields).',
56
72
  };
57
73
 
58
74
  function typeOf(path) {