@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 +42 -4
- package/cli.mjs +28 -12
- package/lib/apca.mjs +55 -0
- package/lib/color.mjs +27 -7
- package/lib/contrast.mjs +23 -1
- package/lib/cvd.mjs +97 -0
- package/lib/resolve.mjs +31 -3
- package/lib/theme/apply.mjs +17 -1
- package/lib/theme/codec.mjs +38 -23
- package/lib/theme/fonts.mjs +3 -1
- package/lib/theme/generate.mjs +27 -4
- package/lib/theme/index.d.mts +75 -0
- package/lib/theme/index.mjs +15 -2
- package/lib/theme/presets.mjs +18 -6
- package/lib/theme/recipe.mjs +39 -0
- package/lib/theme/semantic.mjs +25 -0
- package/lib/theme/shadcn.mjs +265 -0
- package/lib/theme/vars.mjs +25 -0
- package/lib/theme-cli.mjs +98 -6
- package/package.json +1 -1
- package/targets/design-md.mjs +33 -3
- package/targets/llms.mjs +2 -1
- package/targets/nativewind3.mjs +22 -7
- package/targets/shadcn-web.mjs +12 -2
- package/targets/ts-theme.mjs +7 -0
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
|
|
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
|
|
51
|
-
colour used as text, and each status icon on its own tint
|
|
52
|
-
|
|
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,
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
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
|
|
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 → [
|
|
41
|
-
export
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 }, …]. */
|
package/lib/theme/apply.mjs
CHANGED
|
@@ -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) {
|