@halftone-ds/tokens 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hendri van Niekerk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,66 @@
1
- # Temporary Holding Version
1
+ # @halftone-ds/tokens
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The token build behind [Halftone](https://github.com/itsHendri/halftone). One `tokens.json` in the
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.
6
+
7
+ ```bash
8
+ npm install -D @halftone-ds/tokens
9
+ npx halftone-tokens build # write every output in tokens/tokens.config.json
10
+ npx halftone-tokens check # write nothing; exit 1 if an output is stale (CI)
11
+ npx halftone-tokens figma # mirror the tokens into Figma Variables
12
+ ```
13
+
14
+ ## Inputs
15
+
16
+ - `tokens/tokens.json`: `primitive` ramps and scales, and `semantic.color.<name>` with a `{ light, dark }`
17
+ value per colour, using the shadcn names (`background`, `primary`, `primary-foreground`, `muted`, …) plus
18
+ `success`, `warning` and `info`. `semantic.radius.base` sets `--radius`.
19
+ - `tokens/tokens.config.json`: which targets to write, and where (paths relative to the app root):
20
+
21
+ ```json
22
+ {
23
+ "source": "tokens/tokens.json",
24
+ "scan": ["app", "components"],
25
+ "targets": {
26
+ "nativewind3": { "css": "global.css", "tailwind": "tokens/generated/tailwind.theme.js" },
27
+ "ts-theme": { "out": "lib/theme.ts", "navTheme": "expo-router/react-navigation" },
28
+ "shadcn-web": { "out": "app/tokens.css" },
29
+ "design-md": { "out": "DESIGN.md", "name": "My Kit" }
30
+ }
31
+ }
32
+ ```
33
+
34
+ `scan` (optional) lists source folders for the text-on-tint check below.
35
+
36
+ ## Targets
37
+
38
+ | Target | Writes |
39
+ |---|---|
40
+ | `nativewind3` | NativeWind 4 / Tailwind 3 CSS variables (HSL, `:root` + `.dark:root`) and the Tailwind colour + radius map |
41
+ | `shadcn-web` | Tailwind 4 / shadcn v4 CSS: oklch variables in `:root` + `.dark`, `@theme inline`, base layer |
42
+ | `ts-theme` | `THEME` (hex per scheme), `TOKENS` (space, radius, type, motion) and an optional React Navigation theme |
43
+ | `registry-theme` | the theme as shadcn registry `cssVars`, native and web |
44
+ | `design-md` | a [DESIGN.md](https://github.com/google-labs-code/design.md) (spec alpha) |
45
+ | `llms` | a tokens section for `llms.txt` |
46
+ | `figma` | the input `halftone-tokens figma` pushes to Figma Variables |
47
+
48
+ ## Guarantees
49
+
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.
53
+ - **Text on tints.** A status colour as text on its own tint (`text-success` on `bg-success/15`) loses
54
+ contrast fast: 4.09:1 with a typical green. The build works out, per colour, the strongest tint that
55
+ still clears AA in both themes, and writes it into `DESIGN.md` and the `llms` section
56
+ (`primary up to /5, …, destructive never`). With `scan` set, `build` and `check` also read every class
57
+ string in those folders and fail on a tone's text over a tint stronger than that, naming the file and line.
58
+ An element that holds only an icon (3:1) is exempt with a `halftone-tokens-ignore tint-text` comment. A class
59
+ string has to contain both classes, so React Native's `View` > `Text` nesting is out of its reach;
60
+ `DESIGN.md` states the rule for that case.
61
+ - **No drift between platforms.** Every oklch value must render back to the same 8-bit hex as the source,
62
+ so the web theme and the native theme are the same colours.
63
+ - **Radius scale.** `rounded-*` classes are offsets from `radius.lg`, so with the default base each class
64
+ equals its primitive and moving `semantic.radius.base` shifts the whole scale.
65
+
66
+ MIT licence.
package/cli.mjs ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * halftone-tokens — turns tokens/tokens.json into everything an app reads.
4
+ *
5
+ * halftone-tokens build resolve, run the contrast gate, write every target in tokens.config.json
6
+ * halftone-tokens check same, but write nothing; exit 1 if any generated file is stale (CI)
7
+ * halftone-tokens figma mirror the tokens into Figma Variables (see push-figma.mjs)
8
+ * halftone-tokens theme apply, show or list themes (see lib/theme-cli.mjs)
9
+ * halftone-tokens --version
10
+ *
11
+ * --root <dir> app root (default: cwd); every path in the config is relative to it
12
+ * --config <file> default: tokens/tokens.config.json
13
+ *
14
+ * tokens.config.json: { "source": "tokens/tokens.json", "targets": { "<target>": { …options } } }
15
+ * Targets live in ./targets/<name>.mjs and return [{ path, contents }]. All outputs are GENERATED —
16
+ * never hand-edit them.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+
22
+ import { checkContrast, AA, describeTintLimits, textOnTintLimits } from './lib/contrast.mjs';
23
+ import { scanTintText } from './lib/scan.mjs';
24
+ import { loadTokens } from './lib/resolve.mjs';
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+
28
+ /** Flags that take a value; every other --flag is a boolean. */
29
+ const VALUE_FLAGS = new Set(['root', 'config']);
30
+
31
+ function parseArgs(argv) {
32
+ const [command = 'build', ...rest] = argv;
33
+ const flags = {};
34
+ const positional = [];
35
+ for (let i = 0; i < rest.length; i++) {
36
+ if (!rest[i].startsWith('--')) positional.push(rest[i]);
37
+ else if (VALUE_FLAGS.has(rest[i].slice(2))) flags[rest[i].slice(2)] = rest[++i];
38
+ else flags[rest[i].slice(2)] = true;
39
+ }
40
+ return { command, flags, positional };
41
+ }
42
+
43
+ export async function run(argv = process.argv.slice(2)) {
44
+ const { command, flags, positional } = parseArgs(argv);
45
+ if (command === '--version' || command === '-v') {
46
+ console.log(JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).version);
47
+ return;
48
+ }
49
+ const root = resolve(flags.root ?? process.cwd());
50
+
51
+ if (command === 'figma') {
52
+ process.env.KIT_TOKENS_ROOT = root;
53
+ await import(pathToFileURL(join(here, 'push-figma.mjs')).href);
54
+ return;
55
+ }
56
+ if (command !== 'build' && command !== 'check' && command !== 'theme') {
57
+ console.error(`halftone-tokens: unknown command "${command}". Use build, check, theme or figma.`);
58
+ process.exit(2);
59
+ }
60
+
61
+ const configPath = join(root, flags.config ?? 'tokens/tokens.config.json');
62
+ if (!existsSync(configPath)) {
63
+ console.error(`halftone-tokens: no config at ${configPath}`);
64
+ process.exit(2);
65
+ }
66
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
67
+ const source = config.source ?? 'tokens/tokens.json';
68
+
69
+ if (command === 'theme') {
70
+ const { themeCommand } = await import('./lib/theme-cli.mjs');
71
+ const rebuild = ['build', '--root', root, ...(flags.config ? ['--config', flags.config] : [])];
72
+ return themeCommand({ positional, flags, root, sourcePath: join(root, source), config, build: () => run(rebuild) });
73
+ }
74
+ const ctx = await loadTokens(join(root, source));
75
+ ctx.root = root;
76
+ ctx.header = `GENERATED by \`npm run tokens:build\` from ${source} — do not edit by hand.`;
77
+
78
+ const failures = checkContrast(ctx.colors, { strict: true });
79
+ 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` +
82
+ 'Darken the fill, or flip its -foreground between neutral.0 and neutral.950.\n' +
83
+ 'A brightened accent (usually the dark theme) needs dark text, not white.\n' +
84
+ 'Or let the theme generator fix it: `halftone-tokens theme apply <code|preset>` keeps the hue and adjusts the lightness.\n'
85
+ );
86
+ process.exit(1);
87
+ }
88
+
89
+ // The palette passes; now the source: a tone's text on its own tint, beyond what this palette allows.
90
+ if (config.scan?.length) {
91
+ const limits = textOnTintLimits(ctx.colors);
92
+ const problems = scanTintText(root, config.scan, limits);
93
+ 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` +
96
+ `This palette allows: ${describeTintLimits(limits)}.\n` +
97
+ 'Use text-foreground on the tint (let the tint, an icon or a dot carry the colour), or a lighter tint.\n' +
98
+ 'An element that holds only an icon may keep its tone: add a `halftone-tokens-ignore tint-text` comment.\n'
99
+ );
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ const files = [];
105
+ for (const [name, options] of Object.entries(config.targets ?? {})) {
106
+ const { default: target } = await import(pathToFileURL(join(here, 'targets', `${name}.mjs`)).href);
107
+ files.push(...(await target(ctx, options ?? {})));
108
+ }
109
+
110
+ if (command === 'check') {
111
+ const stale = files.filter((f) => {
112
+ const path = join(root, f.path);
113
+ return !existsSync(path) || readFileSync(path, 'utf8') !== f.contents;
114
+ });
115
+ if (stale.length) {
116
+ 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
+ process.exit(1);
118
+ }
119
+ console.log(`halftone-tokens check: ${files.length} files up to date · contrast AA ✓`);
120
+ return;
121
+ }
122
+
123
+ for (const f of files) {
124
+ const path = join(root, f.path);
125
+ mkdirSync(dirname(path), { recursive: true });
126
+ writeFileSync(path, f.contents);
127
+ }
128
+ console.log(`tokens:build → ${files.map((f) => f.path).join(', ')} · contrast AA ✓`);
129
+ }
130
+
131
+ // npm runs bins through a symlink in node_modules/.bin, so compare real paths.
132
+ const isMain = () => {
133
+ try {
134
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
135
+ } catch {
136
+ return false;
137
+ }
138
+ };
139
+ if (process.argv[1] && isMain()) await run();
package/lib/color.mjs ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Colour maths for the token build. Hex in, every format the targets need out:
3
+ * hexToHslTriplet NativeWind 4 / shadcn v3 CSS variables (`221.2 83.2% 53.3%`)
4
+ * hexToOklch Tailwind 4 / shadcn web CSS variables (`oklch(0.546 0.2152 262.88)`)
5
+ * contrast WCAG 2.1 ratio, for the gate in contrast.mjs
6
+ */
7
+
8
+ function parseHex(hex) {
9
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
10
+ if (!m) throw new Error(`Expected a 6-digit hex colour, got "${hex}"`);
11
+ const n = parseInt(m[1], 16);
12
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
13
+ }
14
+
15
+ const toHex = (rgb) => '#' + rgb.map((c) => c.toString(16).padStart(2, '0')).join('');
16
+
17
+ export function hexToHslTriplet(hex) {
18
+ const [r, g, b] = parseHex(hex).map((c) => c / 255);
19
+ const max = Math.max(r, g, b);
20
+ const min = Math.min(r, g, b);
21
+ const l = (max + min) / 2;
22
+ let h = 0;
23
+ let s = 0;
24
+ if (max !== min) {
25
+ const d = max - min;
26
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
27
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
28
+ else if (max === g) h = ((b - r) / d + 2) / 6;
29
+ else h = ((r - g) / d + 4) / 6;
30
+ }
31
+ const round = (x) => Math.round(x * 10) / 10;
32
+ return `${round(h * 360)} ${round(s * 100)}% ${round(l * 100)}%`;
33
+ }
34
+
35
+ // ---------- OKLCH (Björn Ottosson's OKLab, sRGB D65) -----------------------------
36
+
37
+ const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
38
+ const fromLinear = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
39
+
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));
43
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
44
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
45
+ 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;
49
+ const C = Math.hypot(A, B);
50
+ const H = C < 1e-4 ? 0 : ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
51
+ return [L, C < 1e-4 ? 0 : C, H];
52
+ }
53
+
54
+ function oklchToLinearRgb([L, C, H]) {
55
+ const hr = (H * Math.PI) / 180;
56
+ const A = C * Math.cos(hr);
57
+ const B = C * Math.sin(hr);
58
+ const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
59
+ const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
60
+ const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
61
+ return [
62
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
63
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
64
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
65
+ ];
66
+ }
67
+
68
+ /** [L, C, H] → 8-bit sRGB hex (clamped). */
69
+ export function oklchPartsToHex(parts) {
70
+ return toHex(oklchToLinearRgb(parts).map((c) => Math.round(Math.min(1, Math.max(0, fromLinear(c))) * 255)));
71
+ }
72
+
73
+ /** Whether [L, C, H] lies inside sRGB (no channel clipped). */
74
+ export function inGamut(parts, epsilon = 1e-4) {
75
+ return oklchToLinearRgb(parts).every((c) => c >= -epsilon && c <= 1 + epsilon);
76
+ }
77
+
78
+ const fixed = (x, d) => Number(x.toFixed(d));
79
+
80
+ /**
81
+ * Hex → `oklch(L C H)` rounded for CSS: 4 decimals for L and C, 2 for H, more only when a saturated
82
+ * colour needs it to come back as the same 8-bit value. Throws if no precision does, so the web theme
83
+ * can never drift from the native one.
84
+ */
85
+ export function hexToOklch(hex) {
86
+ const [L, C, H] = hexToOklchParts(hex);
87
+ const target = toHex(parseHex(hex));
88
+ for (const d of [4, 5, 6]) {
89
+ const parts = [fixed(L, d), fixed(C, d), fixed(H, d - 2)];
90
+ if (oklchPartsToHex(parts) === target) return `oklch(${parts.join(' ')})`;
91
+ }
92
+ throw new Error(`oklch round-trip drifted for ${hex}`);
93
+ }
94
+
95
+ // ---------- WCAG ----------------------------------------------------------------
96
+
97
+ export function relativeLuminance(hex) {
98
+ const [r, g, b] = parseHex(hex).map((c) => {
99
+ const v = c / 255;
100
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
101
+ });
102
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
103
+ }
104
+
105
+ export function contrast(a, b) {
106
+ const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
107
+ return (hi + 0.05) / (lo + 0.05);
108
+ }
109
+
110
+ /** Composite `hex` at `alpha` over `base`, the way a /NN tint renders. */
111
+ export function blend(hex, base, alpha) {
112
+ const [f, b] = [parseHex(hex), parseHex(base)];
113
+ return toHex(f.map((c, i) => Math.round(alpha * c + (1 - alpha) * b[i])));
114
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Contrast gate. Re-branding tokens.json is the documented workflow, and a pleasant-looking brand
3
+ * colour will happily fail WCAG without anyone noticing. Every pairing the kit actually renders is
4
+ * checked here, and a failure stops the build.
5
+ */
6
+ import { blend, contrast } from './color.mjs';
7
+
8
+ export const AA = 4.5; // WCAG 2.1 AA, normal text. Kit labels are 16px — not "large text".
9
+ export const AA_NON_TEXT = 3; // WCAG 1.4.11, icons and other meaningful non-text.
10
+
11
+ /** The alpha values the kit actually tints with (bg-<tone>/10 etc). */
12
+ const TINTS = [0.1, 0.15, 0.2];
13
+ /** Tones that get used as ink — text-<tone> and coloured icons — not just as fills. */
14
+ export const TONES = ['primary', 'success', 'warning', 'info', 'destructive'];
15
+
16
+ /**
17
+ * Every way the kit puts one token against another:
18
+ * 1. a fill and its own -foreground label
19
+ * 2. body and muted text on each surface
20
+ * 3. a tone used as INK — text-<tone> and coloured icons — on the page and on a card
21
+ * 4. a tone's icon sitting on that same tone's tint, as Alert and IconCircle do
22
+ *
23
+ * 3 and 4 are the ones that matter most for a re-brand: a colour can be a perfectly good
24
+ * fill and still be unreadable as text, which is exactly how an amber warning gets in.
25
+ *
26
+ * `hex` is { name: '#rrggbb' } for one mode. `strict` adds tones as ink on the `muted` and `accent`
27
+ * surfaces too (a link in a muted panel). The build and the theme generator both check strict.
28
+ */
29
+ export function contrastPairs(hex, mode, { strict = false, fg: only } = {}) {
30
+ const out = [];
31
+ // `fg`: only the pairings that token takes part in as the foreground (the theme fixer's inner loop).
32
+ const add = (label, fgName, bgName, fg, bg, threshold) => {
33
+ if (!only || fgName === only) out.push({ mode, label, fgName, bgName, fg, bg, threshold, ratio: contrast(fg, bg) });
34
+ };
35
+
36
+ for (const name of ['primary', 'secondary', 'destructive', 'success', 'warning', 'info', 'accent', 'card', 'popover', 'muted', 'sidebar', 'sidebar-primary', 'sidebar-accent']) {
37
+ const fg = `${name}-foreground`;
38
+ if (hex[name] && hex[fg]) add(`${fg} on ${name}`, fg, name, hex[fg], hex[name], AA);
39
+ }
40
+ for (const surface of ['background', 'card', 'muted']) {
41
+ if (hex['muted-foreground'] && hex[surface]) add(`muted-foreground on ${surface}`, 'muted-foreground', surface, hex['muted-foreground'], hex[surface], AA);
42
+ if (hex.foreground && hex[surface]) add(`foreground on ${surface}`, 'foreground', surface, hex.foreground, hex[surface], AA);
43
+ }
44
+
45
+ // A tone used as ink rather than as a fill.
46
+ for (const tone of TONES) {
47
+ if (!hex[tone]) continue;
48
+ for (const surface of strict ? ['background', 'card', 'muted', 'accent'] : ['background', 'card']) {
49
+ if (hex[surface]) add(`text-${tone} on ${surface}`, tone, surface, hex[tone], hex[surface], AA);
50
+ }
51
+ }
52
+
53
+ // A tone's icon on that tone's own tint — Alert, IconCircle, SwipeToConfirm.
54
+ for (const tone of TONES) {
55
+ if (!hex[tone] || !hex.background) continue;
56
+ for (const alpha of TINTS) {
57
+ add(`${tone} icon on ${tone}/${alpha * 100}`, tone, `${tone}/${alpha * 100}`, hex[tone], blend(hex[tone], hex.background, alpha), AA_NON_TEXT);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** The failing pairings as report lines. */
64
+ export function contrastFailures(hex, mode, options) {
65
+ return contrastPairs(hex, mode, options)
66
+ .filter((r) => r.ratio < r.threshold)
67
+ .map((r) => ` ${mode.padEnd(5)} ${r.label.padEnd(36)} ${r.ratio.toFixed(2)} / ${r.threshold} (${r.fg} on ${r.bg})`);
68
+ }
69
+
70
+ /** Tint strengths a class can ask for (`bg-<tone>/5` … `/20`), for the text-on-tint limits below. */
71
+ export const TEXT_TINTS = [0.05, 0.1, 0.15, 0.2];
72
+
73
+ /**
74
+ * How strong a tint of each tone can be with that same tone's TEXT on it (`text-success` on
75
+ * `bg-success/15`) and still clear AA in both themes: `{ primary: 0.05, destructive: 0, … }`, where 0
76
+ * means never. A different pairing from 3 above (the tone on the page) and 4 (an icon, 3:1): a colour
77
+ * that reads fine on white loses contrast fast on its own tint. Not a gate on the palette, because the
78
+ * kit can always put `text-foreground` on a tint; `halftone-tokens check` holds the source to these limits.
79
+ */
80
+ export function textOnTintLimits(colors) {
81
+ const modes = ['light', 'dark'].map((mode) => Object.fromEntries(colors.map((c) => [c.name, c[mode]])));
82
+ const limits = {};
83
+ for (const tone of TONES) {
84
+ let max = 0;
85
+ for (const alpha of TEXT_TINTS) {
86
+ const ok = modes.every((hex) => hex[tone] && hex.background && contrast(hex[tone], blend(hex[tone], hex.background, alpha)) >= AA);
87
+ if (!ok) break;
88
+ max = alpha;
89
+ }
90
+ limits[tone] = max;
91
+ }
92
+ return limits;
93
+ }
94
+
95
+ /** `{ primary: 0.05, destructive: 0 }` → "primary up to /5, destructive never". */
96
+ export const describeTintLimits = (limits) =>
97
+ Object.entries(limits)
98
+ .map(([tone, max]) => `${tone} ${max ? `up to /${Math.round(max * 100)}` : 'never'}`)
99
+ .join(', ');
100
+
101
+ /** Run the gate over both modes of the resolved colours; returns the failure lines. */
102
+ export function checkContrast(colors, options) {
103
+ const of = (mode) => Object.fromEntries(colors.map((c) => [c.name, c[mode]]));
104
+ return [...contrastFailures(of('light'), 'light', options), ...contrastFailures(of('dark'), 'dark', options)];
105
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Load tokens.json and resolve it once per mode. Style Dictionary resolves {references}; this file
3
+ * handles the light/dark split (a semantic colour's $value is { light, dark }) and hands every
4
+ * target the same context object.
5
+ */
6
+ import StyleDictionary from 'style-dictionary';
7
+ import { readFileSync } from 'node:fs';
8
+ import { DENSITY, DEPTH, shadowCss, TYPE, typeScale } from './theme/recipe.mjs';
9
+
10
+ export const MODES = ['light', 'dark'];
11
+
12
+ function isModed(value) {
13
+ return value && typeof value === 'object' && !Array.isArray(value) && MODES.every((m) => m in value);
14
+ }
15
+
16
+ /** Return a copy of the token tree with every { light, dark } value collapsed to one mode. */
17
+ function forMode(node, mode) {
18
+ if (Array.isArray(node) || node === null || typeof node !== 'object') return node;
19
+ if ('$value' in node) return { ...node, $value: isModed(node.$value) ? node.$value[mode] : node.$value };
20
+ return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, forMode(v, mode)]));
21
+ }
22
+
23
+ async function resolveMode(source, mode) {
24
+ const sd = new StyleDictionary({
25
+ tokens: forMode(source, mode),
26
+ usesDtcg: true,
27
+ log: { verbosity: 'silent' },
28
+ platforms: { kit: { transforms: [] } },
29
+ });
30
+ await sd.hasInitialized;
31
+ const { allTokens } = await sd.getPlatformTokens('kit');
32
+ return allTokens;
33
+ }
34
+
35
+ export const camel = (s) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
36
+ export const isColor = (t) => t.$type === 'color';
37
+
38
+ /** CSS variable name for a semantic token: semantic.color.card-foreground → card-foreground, semantic.radius.base → radius */
39
+ export function cssName(token) {
40
+ const parts = token.path.slice(1).filter((p) => p !== 'color');
41
+ if (parts.at(-1) === 'base') parts.pop();
42
+ return parts.join('-');
43
+ }
44
+
45
+ /**
46
+ * Tailwind radius classes as multiples of primitive.radius.lg. With the default base every class equals
47
+ * its primitive (rounded-xl = radius.xl); moving semantic.radius.base scales the whole set, so a base
48
+ * of 0 makes every rounded-* square.
49
+ */
50
+ function radiusScales(prim) {
51
+ const steps = prim.filter((t) => t.path[1] === 'radius' && t.path.length === 3 && t.path[2] !== 'full');
52
+ const anchor = steps.find((t) => t.path[2] === 'lg').$value;
53
+ return steps.map((t) => ({ name: t.path[2], scale: Number((t.$value / anchor).toFixed(4)) }));
54
+ }
55
+
56
+ export function radiusCalc(scale) {
57
+ return scale === 1 ? 'var(--radius)' : `calc(var(--radius) * ${scale})`;
58
+ }
59
+
60
+ /**
61
+ * Everything a target needs:
62
+ * semLight / semDark semantic tokens per mode (colours + radius), in tokens.json order
63
+ * prim primitive tokens (mode-independent)
64
+ * colors [{ name, light, dark }] semantic colours as hex
65
+ * radius { base: px, steps: [{ name, scale }] }
66
+ * shape { control: px, borderWidth: px, stroke, shadows: { light: { 1: css }, dark },
67
+ * density: { scale, sm, md, lg, x } }
68
+ * (a token file from before themes gets the kit's defaults)
69
+ * type { size, scale, leading, leadingFactor, flow, measure } (semantic.type; defaults
70
+ * for a token file from before the typeset)
71
+ */
72
+ export async function loadTokens(sourcePath) {
73
+ const source = JSON.parse(readFileSync(sourcePath, 'utf8'));
74
+ const [light, dark] = await Promise.all(MODES.map((m) => resolveMode(source, m)));
75
+ const semLight = light.filter((t) => t.path[0] === 'semantic');
76
+ const semDark = dark.filter((t) => t.path[0] === 'semantic');
77
+ const prim = light.filter((t) => t.path[0] === 'primitive');
78
+ const darkHex = Object.fromEntries(semDark.filter(isColor).map((t) => [cssName(t), t.$value.toLowerCase()]));
79
+ const colors = semLight
80
+ .filter(isColor)
81
+ .map((t) => ({ name: cssName(t), light: t.$value.toLowerCase(), dark: darkHex[cssName(t)] }));
82
+ const radius = {
83
+ base: semLight.find((t) => cssName(t) === 'radius').$value,
84
+ steps: radiusScales(prim),
85
+ };
86
+ const find = (list, name) => list.find((t) => cssName(t) === name)?.$value;
87
+ const shadows = (list, mode) =>
88
+ Object.fromEntries(['1', '2', '3'].map((level) => [level, shadowCss(find(list, `shadow-${level}`) ?? DEPTH.soft[level][mode])]));
89
+ const shape = {
90
+ control: find(semLight, 'radius-control') ?? 9999,
91
+ borderWidth: find(semLight, 'border-width') ?? 1,
92
+ stroke: find(semLight, 'icon-stroke') ?? 2,
93
+ shadows: { light: shadows(semLight, 'light'), dark: shadows(semDark, 'dark') },
94
+ density: {
95
+ scale: find(semLight, 'density-scale') ?? DENSITY.comfortable.scale,
96
+ ...Object.fromEntries(['sm', 'md', 'lg', 'x'].map((s) => [s, find(semLight, `density-control-${s}`) ?? DENSITY.comfortable[s]])),
97
+ },
98
+ };
99
+ 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 };
101
+ }
102
+
103
+ /** Primitive tokens under a path prefix, e.g. group(prim, ['font', 'size']) → [{ key: 'xs', value: 12 }, …]. */
104
+ export function group(prim, prefix) {
105
+ return prim
106
+ .filter((t) => t.path.slice(1, 1 + prefix.length).join('.') === prefix.join('.'))
107
+ .map((t) => ({ key: t.path.slice(1 + prefix.length).join('-'), value: t.$value, type: t.$type }));
108
+ }
package/lib/scan.mjs ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Source scan: a tone's text on that tone's own tint, beyond what the palette allows.
3
+ *
4
+ * The contrast gate proves the palette's pairings; it cannot see how components combine classes.
5
+ * `bg-success/15 text-success` looks deliberate and fails AA (4.09:1 with the default palette), so this
6
+ * reads every class string in the configured folders and flags that combination wherever the tint is
7
+ * stronger than `textOnTintLimits` allows. Only unprefixed classes count (`hover:bg-…` is another state).
8
+ *
9
+ * An element that holds only an icon may keep its tone (icons need 3:1, which the gate checks): put
10
+ * `halftone-tokens-ignore tint-text` in a comment on that line or the line above.
11
+ *
12
+ * Limits: a class string has to contain both classes. Text inside a tinted parent (React Native's
13
+ * View > Text) is not seen; DESIGN.md states the rule for those.
14
+ */
15
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
16
+ import { join, relative } from 'node:path';
17
+
18
+ const TONES = ['primary', 'success', 'warning', 'info', 'destructive'];
19
+ const EXTENSIONS = /\.(m?[jt]sx?)$/;
20
+ const SKIP = new Set(['node_modules', '.next', '.expo', 'dist', 'out', 'generated', 'ios', 'android']);
21
+ const PRAGMA = 'halftone-tokens-ignore tint-text';
22
+
23
+ function files(dir) {
24
+ let out = [];
25
+ for (const name of readdirSync(dir)) {
26
+ if (SKIP.has(name) || name.startsWith('.')) continue;
27
+ const full = join(dir, name);
28
+ if (statSync(full).isDirectory()) out = out.concat(files(full));
29
+ else if (EXTENSIONS.test(name)) out.push(full);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** Returns one line per offending class string: `components/x.tsx:12 text-success on bg-success/15 (allowed: up to /5)`. */
35
+ export function scanTintText(root, paths, limits) {
36
+ const problems = [];
37
+ for (const path of paths) {
38
+ let list;
39
+ try {
40
+ list = files(join(root, path));
41
+ } catch {
42
+ continue; // a configured folder that does not exist (yet) is not an error
43
+ }
44
+ for (const file of list) {
45
+ const source = readFileSync(file, 'utf8');
46
+ const lines = source.split('\n');
47
+ for (const m of source.matchAll(/(["'`])((?:(?!\1)[^\\\n]|\\.)*)\1/g)) {
48
+ const classes = m[2].split(/\s+/).filter((c) => c && !c.includes(':'));
49
+ const texts = new Set(classes.map((c) => /^text-([a-z]+)$/.exec(c)?.[1]).filter((t) => TONES.includes(t)));
50
+ if (!texts.size) continue;
51
+ for (const c of classes) {
52
+ const tint = /^bg-([a-z]+)\/(\d+)$/.exec(c);
53
+ if (!tint || !texts.has(tint[1])) continue;
54
+ const [, tone, pct] = tint;
55
+ const alpha = Number(pct) / 100;
56
+ if (alpha <= limits[tone]) continue;
57
+ const lineNo = source.slice(0, m.index).split('\n').length;
58
+ if (`${lines[lineNo - 1]}\n${lines[lineNo - 2] ?? ''}`.includes(PRAGMA)) continue;
59
+ const allowed = limits[tone] ? `up to /${Math.round(limits[tone] * 100)}` : 'never';
60
+ problems.push(` ${relative(root, file)}:${lineNo} text-${tone} on bg-${tone}/${pct} (allowed: ${allowed})`);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return problems;
66
+ }