@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 +84 -4
- package/cli.mjs +33 -14
- package/lib/apca.mjs +55 -0
- package/lib/color.mjs +27 -7
- package/lib/contrast.mjs +56 -1
- package/lib/cvd.mjs +97 -0
- package/lib/resolve.mjs +100 -12
- package/lib/system/generate.mjs +212 -0
- package/lib/system/index.d.mts +168 -0
- package/lib/system/index.mjs +33 -0
- package/lib/system/migrate.mjs +38 -0
- package/lib/system/schema.mjs +511 -0
- package/lib/theme/apply.mjs +191 -16
- package/lib/theme/codec.mjs +38 -23
- package/lib/theme/components.mjs +471 -0
- package/lib/theme/extras.mjs +105 -0
- package/lib/theme/fix.mjs +63 -10
- package/lib/theme/fonts.mjs +16 -1
- package/lib/theme/generate.mjs +114 -13
- package/lib/theme/index.d.mts +166 -2
- package/lib/theme/index.mjs +33 -3
- package/lib/theme/presets.mjs +19 -7
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +107 -0
- package/lib/theme/semantic.mjs +43 -6
- package/lib/theme/shadcn.mjs +265 -0
- package/lib/theme/vars.mjs +81 -3
- package/lib/theme-cli.mjs +179 -17
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +64 -17
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +9 -4
- package/targets/nativewind3.mjs +77 -12
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +106 -6
- package/targets/ts-theme.mjs +42 -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,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
|
|
51
|
-
colour used as text, and each status icon on its own tint
|
|
52
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
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
|
|
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 → [
|
|
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,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})`);
|