@radicool/throughline 0.18.0 → 0.20.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 +2 -2
- package/adapters/codex/prompts/component-builder.md +24 -1
- package/adapters/codex/prompts/document-component.md +10 -1
- package/adapters/codex/prompts/icon-system-builder.md +4 -0
- package/adapters/codex/prompts/storybook-chromatic-builder.md +114 -7
- package/adapters/codex/prompts/token-builder.md +110 -3
- package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
- package/adapters/codex/prompts/token-sheet-builder.md +4 -0
- package/adapters/codex/prompts/token-sync-layer.md +140 -20
- package/adapters/cursor/.cursor/commands/document-component.md +10 -1
- package/adapters/cursor/.cursor/rules/component-builder.mdc +24 -1
- package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +4 -0
- package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +114 -7
- package/adapters/cursor/.cursor/rules/token-builder.mdc +110 -3
- package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +2 -0
- package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +4 -0
- package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +140 -20
- package/adapters/generic/commands/document-component.md +10 -1
- package/adapters/generic/skills/component-builder/SKILL.md +24 -1
- package/adapters/generic/skills/icon-system-builder/SKILL.md +4 -0
- package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +114 -7
- package/adapters/generic/skills/token-builder/SKILL.md +110 -3
- package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
- package/adapters/generic/skills/token-sheet-builder/SKILL.md +4 -0
- package/adapters/generic/skills/token-sync-layer/SKILL.md +140 -20
- package/package.json +1 -1
- package/references/component-doc-schema.md +11 -1
- package/references/figma-component-standards.md +14 -2
- package/references/figma-scripting.md +12 -2
- package/references/manifest-schema.md +42 -2
- package/references/native-adapter-config.md +112 -25
- package/references/proof-bundle.md +305 -0
- package/references/sync-adapters.md +38 -53
- package/scripts/README.md +140 -6
- package/scripts/build-doc-card-builder.mjs +1 -1
- package/scripts/grep-color-usage.mjs +10 -30
- package/scripts/guard-token-removal.mjs +10 -26
- package/scripts/lib/component-states.mjs +56 -0
- package/scripts/lib/contrast.mjs +117 -0
- package/scripts/lib/dtcg.mjs +123 -4
- package/scripts/lib/native-literal.mjs +22 -2
- package/scripts/lib/proof.mjs +186 -0
- package/scripts/lib/sd-native.mjs +112 -25
- package/scripts/lib/source-scan.mjs +45 -0
- package/scripts/validate-adherence.mjs +895 -0
- package/scripts/validate-token-output.mjs +611 -46
- package/scripts/verify-check.mjs +710 -0
|
@@ -6,10 +6,17 @@
|
|
|
6
6
|
// Usage:
|
|
7
7
|
// node grep-color-usage.mjs --root <dir> [--config <patterns.json>] [--out <counts.json>]
|
|
8
8
|
// patterns.json: { "<category>": { "files": "<regex>", "pattern": "<regex with g flag>" }, ... }
|
|
9
|
-
import { readFileSync,
|
|
10
|
-
import {
|
|
9
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { relative } from 'node:path';
|
|
11
11
|
import { parseArgs } from 'node:util';
|
|
12
12
|
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import { walk, DEFAULT_EXCLUDES, SOURCE_EXT } from './lib/source-scan.mjs';
|
|
14
|
+
|
|
15
|
+
// This script has exported `walk` and DEFAULT_EXCLUDES since it was written;
|
|
16
|
+
// keep that surface intact now that both live one file over. SOURCE_EXT is not
|
|
17
|
+
// re-exported because it was never exported from here — import it from
|
|
18
|
+
// lib/source-scan.mjs. `walk`'s second parameter is now an options object.
|
|
19
|
+
export { walk, DEFAULT_EXCLUDES };
|
|
13
20
|
|
|
14
21
|
// The five categories from the case study. Each: which files it applies to, and the
|
|
15
22
|
// (global) match pattern. Tuned defaults — design-system-audit may override per repo.
|
|
@@ -36,33 +43,6 @@ export const DEFAULT_CATEGORIES = {
|
|
|
36
43
|
},
|
|
37
44
|
};
|
|
38
45
|
|
|
39
|
-
export const DEFAULT_EXCLUDES = [
|
|
40
|
-
/(^|\/)node_modules(\/|$)/,
|
|
41
|
-
/(^|\/)generated(\/|$)/,
|
|
42
|
-
/\.generated\./,
|
|
43
|
-
/\.test\./,
|
|
44
|
-
/\.spec\./,
|
|
45
|
-
/(^|\/)__tests__(\/|$)/,
|
|
46
|
-
/(^|\/)dist(\/|$)/,
|
|
47
|
-
/(^|\/)\.next(\/|$)/,
|
|
48
|
-
];
|
|
49
|
-
|
|
50
|
-
// Source extensions worth opening at all (union of every category's `files`).
|
|
51
|
-
const SOURCE_EXT = /\.(scss|sass|css|tsx?|jsx?|mjs|cjs|vue|svelte|html|svg)$/;
|
|
52
|
-
|
|
53
|
-
export function* walk(root, excludes = DEFAULT_EXCLUDES) {
|
|
54
|
-
for (const entry of readdirSync(root)) {
|
|
55
|
-
const full = join(root, entry);
|
|
56
|
-
if (excludes.some((re) => re.test(full))) continue;
|
|
57
|
-
const st = statSync(full);
|
|
58
|
-
if (st.isDirectory()) {
|
|
59
|
-
yield* walk(full, excludes);
|
|
60
|
-
} else if (SOURCE_EXT.test(full)) {
|
|
61
|
-
yield full;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
46
|
// Count matches per category in one file. A category contributes only if its `files`
|
|
67
47
|
// regex matches this path. Returns { <category>: count } for every category key.
|
|
68
48
|
export function scanFile(path, categories) {
|
|
@@ -82,7 +62,7 @@ export function grepColorUsage(root, categories = DEFAULT_CATEGORIES, excludes =
|
|
|
82
62
|
const counts = {};
|
|
83
63
|
for (const key of Object.keys(categories)) counts[key] = 0;
|
|
84
64
|
const byFile = [];
|
|
85
|
-
for (const file of walk(root, excludes)) {
|
|
65
|
+
for (const file of walk(root, { excludes, fileFilter: SOURCE_EXT })) {
|
|
86
66
|
const fileCounts = scanFile(file, categories);
|
|
87
67
|
const total = Object.values(fileCounts).reduce((a, b) => a + b, 0);
|
|
88
68
|
if (total > 0) {
|
|
@@ -7,34 +7,16 @@
|
|
|
7
7
|
// Usage:
|
|
8
8
|
// node guard-token-removal.mjs --root <dir> --symbols <symbols.txt>
|
|
9
9
|
// symbols file: one symbol per line (blank lines and # comments ignored)
|
|
10
|
-
import { readFileSync
|
|
11
|
-
import {
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { relative } from 'node:path';
|
|
12
12
|
import { parseArgs } from 'node:util';
|
|
13
13
|
import { pathToFileURL } from 'node:url';
|
|
14
|
+
import { walk, DEFAULT_EXCLUDES } from './lib/source-scan.mjs';
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/\.test\./,
|
|
20
|
-
/\.spec\./,
|
|
21
|
-
/(^|\/)__tests__(\/|$)/,
|
|
22
|
-
/(^|\/)dist(\/|$)/,
|
|
23
|
-
/(^|\/)\.next(\/|$)/,
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
export function* walk(root, excludes = DEFAULT_EXCLUDES) {
|
|
27
|
-
for (const entry of readdirSync(root)) {
|
|
28
|
-
const full = join(root, entry);
|
|
29
|
-
if (excludes.some((re) => re.test(full))) continue;
|
|
30
|
-
const st = statSync(full);
|
|
31
|
-
if (st.isDirectory()) {
|
|
32
|
-
yield* walk(full, excludes);
|
|
33
|
-
} else if (/\.tsx?$/.test(full)) {
|
|
34
|
-
yield full;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
16
|
+
// This script has exported `walk` and DEFAULT_EXCLUDES since it was written;
|
|
17
|
+
// keep that surface intact now that both live one file over. `walk`'s second
|
|
18
|
+
// parameter is now an options object.
|
|
19
|
+
export { walk, DEFAULT_EXCLUDES };
|
|
38
20
|
|
|
39
21
|
export function scanFile(path, symbols) {
|
|
40
22
|
const lines = readFileSync(path, 'utf8').split('\n');
|
|
@@ -51,7 +33,9 @@ export function scanFile(path, symbols) {
|
|
|
51
33
|
|
|
52
34
|
export function guard(root, symbols, excludes = DEFAULT_EXCLUDES) {
|
|
53
35
|
const findings = [];
|
|
54
|
-
|
|
36
|
+
// .ts/.tsx is this guard's own contract, so the filter stays here rather
|
|
37
|
+
// than moving into the shared walker's default.
|
|
38
|
+
for (const file of walk(root, { excludes, fileFilter: /\.tsx?$/ })) {
|
|
55
39
|
for (const hit of scanFile(file, symbols)) {
|
|
56
40
|
findings.push({ file: relative(root, file), ...hit });
|
|
57
41
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The archetype baseline for `state-incomplete` (`docs/specs/2026-09-12-verification-proof-bundle.md`,
|
|
2
|
+
// step 3). Source of truth for the prose is `references/figma-component-standards.md:126-138`.
|
|
3
|
+
//
|
|
4
|
+
// `default` is deliberately excluded from every baseline below: documenting the
|
|
5
|
+
// resting state is not documentation, and the Figma matrix requirement for it is
|
|
6
|
+
// the executor's, not this table's. Conditional states (`loading`, `error`,
|
|
7
|
+
// `success`, `selected`) are deliberately excluded too — whether one applies to a
|
|
8
|
+
// given component is a design judgment, not something a baseline can assert.
|
|
9
|
+
//
|
|
10
|
+
// MAINTENANCE: a change to the prose baseline must change this table.
|
|
11
|
+
|
|
12
|
+
import { normalizeName } from './source-scan.mjs';
|
|
13
|
+
|
|
14
|
+
export const ARCHETYPES = ['button', 'input', 'choice', 'card', 'modal', 'badge', 'other'];
|
|
15
|
+
|
|
16
|
+
export const BASELINE_STATES = {
|
|
17
|
+
button: ['hover', 'focus', 'active', 'disabled'],
|
|
18
|
+
input: ['hover', 'focus', 'disabled'],
|
|
19
|
+
choice: ['hover', 'focus', 'active', 'disabled'],
|
|
20
|
+
card: [],
|
|
21
|
+
modal: [],
|
|
22
|
+
badge: [],
|
|
23
|
+
other: [],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const NAME_SYNONYMS = {
|
|
27
|
+
button: 'button',
|
|
28
|
+
input: 'input',
|
|
29
|
+
textfield: 'input',
|
|
30
|
+
textinput: 'input',
|
|
31
|
+
checkbox: 'choice',
|
|
32
|
+
radio: 'choice',
|
|
33
|
+
toggle: 'choice',
|
|
34
|
+
switch: 'choice',
|
|
35
|
+
chip: 'choice',
|
|
36
|
+
card: 'card',
|
|
37
|
+
modal: 'modal',
|
|
38
|
+
dialog: 'modal',
|
|
39
|
+
badge: 'badge',
|
|
40
|
+
tag: 'badge',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function resolveArchetype(record) {
|
|
44
|
+
if (ARCHETYPES.includes(record.archetype)) return record.archetype;
|
|
45
|
+
const bySynonym = NAME_SYNONYMS[normalizeName(record.name)];
|
|
46
|
+
return bySynonym ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function missingStates(record) {
|
|
50
|
+
const archetype = resolveArchetype(record);
|
|
51
|
+
if (archetype === null) return [];
|
|
52
|
+
const baseline = BASELINE_STATES[archetype];
|
|
53
|
+
if (baseline.length === 0) return [];
|
|
54
|
+
const recordedKeys = new Set(Object.keys(record.states ?? {}).map(normalizeName));
|
|
55
|
+
return baseline.filter((state) => !recordedKeys.has(normalizeName(state)));
|
|
56
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The WCAG contrast maths and the semantic colour pair table behind the
|
|
2
|
+
// `color-contrast` rule (`docs/specs/2026-09-12-token-contrast-validation.md`,
|
|
3
|
+
// step 1). Source of truth for the prose contract is
|
|
4
|
+
// `skills/token-builder/SKILL.md:250-265`.
|
|
5
|
+
//
|
|
6
|
+
// MAINTENANCE: a change to the semantic role set must change this table.
|
|
7
|
+
|
|
8
|
+
export const AA_NORMAL_TEXT = 4.5;
|
|
9
|
+
|
|
10
|
+
// `text/disabled` is excluded: WCAG 1.4.3 exempts inactive user-interface
|
|
11
|
+
// components from the contrast minimum, and a disabled role is deliberately
|
|
12
|
+
// low-contrast — gating it would fail correct systems.
|
|
13
|
+
//
|
|
14
|
+
// `bg/subtle` and `bg/muted` are deliberately not paired in v1 (Open
|
|
15
|
+
// questions) — a system is free to use those roles for non-text surfaces, so
|
|
16
|
+
// pairing them risks the first false failures in the table.
|
|
17
|
+
//
|
|
18
|
+
// `border/*` needs the 3:1 non-text threshold (WCAG 1.4.11) and is a separate
|
|
19
|
+
// table; this one is text-on-surface only.
|
|
20
|
+
//
|
|
21
|
+
// One threshold is applied to every pair because a colour role does not say
|
|
22
|
+
// what text size it is used at — `text/primary` is body copy as well as
|
|
23
|
+
// headings, so 4.5:1 is the binding requirement for it anyway.
|
|
24
|
+
export const CONTRAST_PAIRS = [
|
|
25
|
+
{
|
|
26
|
+
fg: 'color.text.primary',
|
|
27
|
+
bg: 'color.bg.default',
|
|
28
|
+
threshold: AA_NORMAL_TEXT,
|
|
29
|
+
why: 'the default reading pair; if this fails, body copy fails everywhere at once',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
fg: 'color.text.secondary',
|
|
33
|
+
bg: 'color.bg.default',
|
|
34
|
+
threshold: AA_NORMAL_TEXT,
|
|
35
|
+
why: 'secondary text is still text, and still has to clear AA on the same surface primary does',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
fg: 'color.text.link',
|
|
39
|
+
bg: 'color.bg.default',
|
|
40
|
+
threshold: AA_NORMAL_TEXT,
|
|
41
|
+
why: 'a link that fails contrast is a link nobody can find, regardless of colour meaning',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
fg: 'color.text.onEmphasis',
|
|
45
|
+
bg: 'color.bg.emphasis',
|
|
46
|
+
threshold: AA_NORMAL_TEXT,
|
|
47
|
+
why: 'the role exists because it sits on the emphasis fill; that is the whole reason it is not `text/inverse`',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
fg: 'color.text.inverse',
|
|
51
|
+
bg: 'color.bg.inverse',
|
|
52
|
+
threshold: AA_NORMAL_TEXT,
|
|
53
|
+
why: 'the inverse pair flips with the theme, and both sides have to keep clearing together',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
fg: 'color.status.success.text',
|
|
57
|
+
bg: 'color.status.success.bg',
|
|
58
|
+
threshold: AA_NORMAL_TEXT,
|
|
59
|
+
why: 'a status text role exists to sit on that status\'s own background, not any other surface',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
fg: 'color.status.warning.text',
|
|
63
|
+
bg: 'color.status.warning.bg',
|
|
64
|
+
threshold: AA_NORMAL_TEXT,
|
|
65
|
+
why: 'a status text role exists to sit on that status\'s own background, not any other surface',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
fg: 'color.status.danger.text',
|
|
69
|
+
bg: 'color.status.danger.bg',
|
|
70
|
+
threshold: AA_NORMAL_TEXT,
|
|
71
|
+
why: 'a status text role exists to sit on that status\'s own background, not any other surface',
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// WCAG 2.x sRGB relative luminance over 0-255 channels.
|
|
76
|
+
export function relativeLuminance({ r, g, b }) {
|
|
77
|
+
const linearize = (channel) => {
|
|
78
|
+
const c = channel / 255;
|
|
79
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
80
|
+
};
|
|
81
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Takes two `#rrggbb` strings (no alpha; the caller composites first) and
|
|
85
|
+
// returns the raw ratio, unrounded.
|
|
86
|
+
export function contrastRatio(hexA, hexB) {
|
|
87
|
+
const lumA = relativeLuminance(parseHex(hexA));
|
|
88
|
+
const lumB = relativeLuminance(parseHex(hexB));
|
|
89
|
+
const lighter = Math.max(lumA, lumB);
|
|
90
|
+
const darker = Math.min(lumA, lumB);
|
|
91
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// `#rrggbb` or `#rrggbbaa` → `{ r, g, b, a }` with `a` in 0-1. `null` for
|
|
95
|
+
// anything else.
|
|
96
|
+
export function parseHex(hex) {
|
|
97
|
+
if (typeof hex !== 'string') return null;
|
|
98
|
+
const match = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(hex);
|
|
99
|
+
if (!match) return null;
|
|
100
|
+
const digits = match[1];
|
|
101
|
+
const r = parseInt(digits.slice(0, 2), 16);
|
|
102
|
+
const g = parseInt(digits.slice(2, 4), 16);
|
|
103
|
+
const b = parseInt(digits.slice(4, 6), 16);
|
|
104
|
+
const a = digits.length === 8 ? parseInt(digits.slice(6, 8), 16) / 255 : 1;
|
|
105
|
+
return { r, g, b, a };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The `#rrggbb` of `fg` drawn over `bg` with src-over (`out = fg*a + bg*(1-a)`).
|
|
109
|
+
export function composite(fgHex, bgHex) {
|
|
110
|
+
const fg = parseHex(fgHex);
|
|
111
|
+
if (fg.a === 0) return bgHex;
|
|
112
|
+
if (fg.a === 1) return fgHex.slice(0, 7);
|
|
113
|
+
const bg = parseHex(bgHex);
|
|
114
|
+
const mix = (fgChannel, bgChannel) => Math.round(fgChannel * fg.a + bgChannel * (1 - fg.a));
|
|
115
|
+
const toHex = (value) => value.toString(16).padStart(2, '0');
|
|
116
|
+
return `#${toHex(mix(fg.r, bg.r))}${toHex(mix(fg.g, bg.g))}${toHex(mix(fg.b, bg.b))}`;
|
|
117
|
+
}
|
package/scripts/lib/dtcg.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Shared DTCG reading: flatten a token tree to dot-paths, resolve {alias} chains.
|
|
2
|
-
// Zero dependencies. Consumed by validate-crosswalk.mjs
|
|
3
|
-
//
|
|
2
|
+
// Zero dependencies. Consumed by validate-crosswalk.mjs, validate-token-output.mjs,
|
|
3
|
+
// validate-adherence.mjs, verify-check.mjs and lib/sd-native.mjs, and copied
|
|
4
|
+
// alongside them when a skill installs any of those.
|
|
4
5
|
|
|
5
6
|
const REF = /^\{([^}]+)\}$/;
|
|
6
7
|
|
|
8
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
9
|
+
|
|
7
10
|
// The typographic member names DTCG §9.8 fixes at MUST level, the unit gate a
|
|
8
11
|
// text-role dimension must pass, and this project's $extensions namespace.
|
|
9
12
|
//
|
|
@@ -16,6 +19,42 @@ export const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight
|
|
|
16
19
|
export const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
|
|
17
20
|
export const EXT_NS = 'com.radicool.throughline';
|
|
18
21
|
|
|
22
|
+
// Read this project's $extensions namespace off a node, refusing a shape that
|
|
23
|
+
// cannot hold one.
|
|
24
|
+
//
|
|
25
|
+
// A DTCG $extensions namespace key may hold ANY JSON value, so a source that
|
|
26
|
+
// authored ours as a string is CONFORMANT input this module does not handle —
|
|
27
|
+
// not malformed input. That distinction is why it gets a diagnostic rather than
|
|
28
|
+
// a shrug: the module's contract is that it consumes conformant DTCG.
|
|
29
|
+
//
|
|
30
|
+
// Before #62 the three places that read this namespace all hit the `in`
|
|
31
|
+
// operator on a primitive and threw a bare TypeError naming no token, no path
|
|
32
|
+
// and no value — out of step with every other diagnostic here. nativeSources
|
|
33
|
+
// names the colliding path and both files; the hoist-collision throw names both
|
|
34
|
+
// paths and the value it would overwrite; nativePlatform names the unknown
|
|
35
|
+
// platform and the expected set.
|
|
36
|
+
export function extNamespace(node, path) {
|
|
37
|
+
const ext = node.$extensions;
|
|
38
|
+
if (ext === undefined) return undefined;
|
|
39
|
+
if (!isPlainObject(ext)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`token "${path}" has a $extensions that is ${JSON.stringify(ext)}, not an object.\n` +
|
|
42
|
+
'DTCG 5.4 makes $extensions a map of namespace keys. Remove it, or give it the shape ' +
|
|
43
|
+
`{ "${EXT_NS}": { "nativeUnit": "text" } }.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const ns = ext[EXT_NS];
|
|
47
|
+
if (ns === undefined) return undefined;
|
|
48
|
+
if (!isPlainObject(ns)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`token "${path}" has $extensions["${EXT_NS}"] set to ${JSON.stringify(ns)}, not an object.\n` +
|
|
51
|
+
`The namespace holds named settings, so a bare value cannot be read. Write ` +
|
|
52
|
+
`{ "nativeUnit": ${JSON.stringify(ns)} } if that is the setting you meant.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return ns;
|
|
56
|
+
}
|
|
57
|
+
|
|
19
58
|
// Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
|
|
20
59
|
//
|
|
21
60
|
// A node carrying BOTH a $value and children yields its own value AND is descended
|
|
@@ -63,6 +102,87 @@ export function flattenDtcgTypes(obj, prefix = [], out = {}, groupType = undefin
|
|
|
63
102
|
return out;
|
|
64
103
|
}
|
|
65
104
|
|
|
105
|
+
// DTCG 5.2.2 PLUS the one repair this pipeline applies on top of it, so that
|
|
106
|
+
// anything reasoning about what the build emits reads the same types the build
|
|
107
|
+
// used. flattenDtcgTypes is the spec; this is the spec as this build resolves it.
|
|
108
|
+
//
|
|
109
|
+
// The repair is hoistDualNodes' $type carry. A dual node is a token, not a group
|
|
110
|
+
// (DTCG 6.1), so it is not its children's inheritance source and 5.2.2 gives an
|
|
111
|
+
// untyped child of one no type at all. The hoist then makes that child a sibling
|
|
112
|
+
// of the dual node, which destroys the last relationship it had — so where NO
|
|
113
|
+
// enclosing group supplies a type, the hoist stamps the dual node's own. That is
|
|
114
|
+
// a repair for what the hoist broke, not a reading of the source, which is why
|
|
115
|
+
// it does not belong in flattenDtcgTypes.
|
|
116
|
+
//
|
|
117
|
+
// It has to be modelled somewhere, though, because two things that reason about
|
|
118
|
+
// types could not see it: classification, which runs before the hoist and so
|
|
119
|
+
// declined a child the pipeline goes on to type (#89), and the unitless-dimension
|
|
120
|
+
// advisory, which reads the raw source (#71). Both were silent on shapes the
|
|
121
|
+
// build handles.
|
|
122
|
+
//
|
|
123
|
+
// The conditions mirror hoistDualNodes exactly — an untyped TOKEN child (#67
|
|
124
|
+
// restricted it to those), a dual node that has a $type, no enclosing group type,
|
|
125
|
+
// and a value that is not a whole-value reference. That last one is why this must
|
|
126
|
+
// run on the RAW tree: resolveInPlace rewrites a reference to its literal, and
|
|
127
|
+
// the WeakSet the hoist consults for it does not survive into a fresh call.
|
|
128
|
+
//
|
|
129
|
+
// Keyed on pre-hoist paths, like flattenDtcgTypes, because every consumer of this
|
|
130
|
+
// map runs before the hoist or reports against source paths.
|
|
131
|
+
export function flattenPipelineTypes(dict) {
|
|
132
|
+
const types = flattenDtcgTypes(dict);
|
|
133
|
+
(function walk(node, prefix, groupType) {
|
|
134
|
+
const inherited = '$value' in node ? groupType : (node.$type ?? groupType);
|
|
135
|
+
for (const [key, val] of Object.entries(node)) {
|
|
136
|
+
if (key.startsWith('$') || !isPlainObject(val)) continue;
|
|
137
|
+
const path = [...prefix, key];
|
|
138
|
+
if ('$value' in val && '$type' in val && inherited === undefined) {
|
|
139
|
+
for (const [childKey, childVal] of Object.entries(val)) {
|
|
140
|
+
if (childKey.startsWith('$') || !isPlainObject(childVal)) continue;
|
|
141
|
+
if ('$value' in childVal && !('$type' in childVal) && !REF.test(String(childVal.$value).trim())) {
|
|
142
|
+
types[[...path, childKey].join('.')] = val.$type;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
walk(val, path, inherited);
|
|
147
|
+
}
|
|
148
|
+
})(dict, [], undefined);
|
|
149
|
+
return types;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Collect nodes carrying BOTH a $value and child tokens or groups.
|
|
153
|
+
//
|
|
154
|
+
// This shape is invalid DTCG. Format Module, Draft Community Group Report of
|
|
155
|
+
// 30 July 2026, §6.1: "The presence of a $value property definitively identifies
|
|
156
|
+
// an object as a token. If an object contains both $value and child
|
|
157
|
+
// tokens/groups, this creates an invalid structure where the object cannot be
|
|
158
|
+
// both a token and a group simultaneously. Tools MUST report this as an error."
|
|
159
|
+
// The prohibition is deliberate rather than an oversight — §6.2 defines $root as
|
|
160
|
+
// the sanctioned way for a group to carry a base value alongside children, which
|
|
161
|
+
// is exactly what a dual node is reaching for.
|
|
162
|
+
//
|
|
163
|
+
// Collecting them is NOT a step towards rejecting them. Figma-derived sources
|
|
164
|
+
// emit dual nodes by the dozen, hoistDualNodes exists precisely to handle them,
|
|
165
|
+
// and refusing them would make this tool useless against the sources it targets.
|
|
166
|
+
// That behaviour is unchanged. What was missing is telling the author their
|
|
167
|
+
// source is non-conforming, which nothing did — so someone hand-authoring
|
|
168
|
+
// text.sm with both a value and a lineHeight child had no way to learn that
|
|
169
|
+
// $root is the blessed spelling.
|
|
170
|
+
//
|
|
171
|
+
// Lives here because flattenDtcg already walks this tree and already descends
|
|
172
|
+
// into dual nodes on purpose, so the knowledge is present and only the reporting
|
|
173
|
+
// was absent.
|
|
174
|
+
export function findDualNodes(obj, prefix = [], out = []) {
|
|
175
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
176
|
+
if (key.startsWith('$') || !isPlainObject(val)) continue;
|
|
177
|
+
const path = [...prefix, key];
|
|
178
|
+
if ('$value' in val && Object.entries(val).some(([k, v]) => !k.startsWith('$') && isPlainObject(v))) {
|
|
179
|
+
out.push(path.join('.'));
|
|
180
|
+
}
|
|
181
|
+
findDualNodes(val, path, out);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
66
186
|
// Follow {alias} chains to a leaf literal. Throws on missing or circular refs.
|
|
67
187
|
export function resolveValue(name, flat, seen = new Set()) {
|
|
68
188
|
if (!(name in flat)) throw new Error(`token "${name}" not found in DTCG source`);
|
|
@@ -104,7 +224,6 @@ export function findModeCollisions(sources) {
|
|
|
104
224
|
//
|
|
105
225
|
// Each source is cloned on the way in. Merging the caller's own objects would
|
|
106
226
|
// mutate the token trees it still holds, and the validator reads them again.
|
|
107
|
-
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
108
227
|
|
|
109
228
|
function mergeInto(target, src) {
|
|
110
229
|
for (const [key, val] of Object.entries(src)) {
|
|
@@ -196,7 +315,7 @@ export function textRoleGraph(dict) {
|
|
|
196
315
|
types[dotted] === 'dimension' &&
|
|
197
316
|
TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
|
|
198
317
|
!referrers.has(dotted) &&
|
|
199
|
-
!('nativeUnit' in (val
|
|
318
|
+
!('nativeUnit' in (extNamespace(val, dotted) ?? {})) &&
|
|
200
319
|
inferredGroups.has(group)
|
|
201
320
|
) {
|
|
202
321
|
unreferencedSiblings.push({ path: dotted, group });
|
|
@@ -64,8 +64,28 @@ export const GRAMMAR = {
|
|
|
64
64
|
// `var` are valid identifiers, and `color-mix` has a rescue in sd-native.mjs
|
|
65
65
|
// that merely did not match this variant. So they must reach the output and
|
|
66
66
|
// fail loudly under no-foreign-syntax, never be silently dropped by a filter.
|
|
67
|
-
// Kept here, beside the grammar, so the build and the gate cannot drift apart
|
|
68
|
-
|
|
67
|
+
// Kept here, beside the grammar, so the build and the gate cannot drift apart —
|
|
68
|
+
// which until #57 was a promise this file made and did not keep. The gate
|
|
69
|
+
// defined its own independent copy of the same alternation, so adding a fourth
|
|
70
|
+
// name here would have taught the filter to keep a construct the gate had never
|
|
71
|
+
// been taught to name. One list now, two anchorings derived from it.
|
|
72
|
+
//
|
|
73
|
+
// The BUILD anchors: only a construct that LEADS the value is exempt from the
|
|
74
|
+
// output filter, because only then is the whole value the unimplemented rescue.
|
|
75
|
+
// The GATE does not anchor: it names the construct wherever it appears.
|
|
76
|
+
//
|
|
77
|
+
// The asymmetry is deliberate and it is a stated limit, not a closed class.
|
|
78
|
+
// `rgba(var(--brand), 0.5)` is dropped by the filter rather than kept, because
|
|
79
|
+
// the module cannot tell an unimplemented rescue nested inside a rescuable
|
|
80
|
+
// function from one nested inside `linear-gradient(...)`, which has no native
|
|
81
|
+
// form at any depth. Closing that needs a notion of which outer functions are
|
|
82
|
+
// rescuable, which does not exist here. What #57 does instead is make the drop
|
|
83
|
+
// NAMED rather than counted — see the unemitted-token report in
|
|
84
|
+
// validate-token-output.mjs.
|
|
85
|
+
export const CSS_CONSTRUCT_NAMES = ['color-mix', 'calc', 'var'];
|
|
86
|
+
const CSS_CONSTRUCT_ALT = CSS_CONSTRUCT_NAMES.join('|');
|
|
87
|
+
export const CSS_CONSTRUCT = new RegExp(`^(?:${CSS_CONSTRUCT_ALT})\\s*\\(`);
|
|
88
|
+
export const CSS_CONSTRUCT_ANYWHERE = new RegExp(`(?:${CSS_CONSTRUCT_ALT})\\s*\\(`);
|
|
69
89
|
|
|
70
90
|
export function parseLiteral(value, grammar = {}) {
|
|
71
91
|
const s = String(value);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// The proof-bundle store (`docs/specs/2026-09-12-verification-proof-bundle.md`,
|
|
2
|
+
// step 4): load, merge and fingerprint the per-stage files at
|
|
3
|
+
// `design-system/proof/<stage>.json`, and read the manifest's adoption state
|
|
4
|
+
// for each stage. Zero dependencies.
|
|
5
|
+
//
|
|
6
|
+
// See `references/proof-bundle.md` for the file shape and the vocabulary this
|
|
7
|
+
// module defines.
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { stableStringify, fingerprint } from './doc-record.mjs';
|
|
11
|
+
|
|
12
|
+
export const PROOF_DIR = 'design-system/proof';
|
|
13
|
+
|
|
14
|
+
// The stage vocabulary. A change here must also update the stage table in
|
|
15
|
+
// `references/proof-bundle.md`: the constant and the prose are one vocabulary,
|
|
16
|
+
// and a stage this list accepts but the reference does not name is a stage
|
|
17
|
+
// nobody knows how to record.
|
|
18
|
+
//
|
|
19
|
+
// `token-builder` is system-wide. The vocabulary names the places where the
|
|
20
|
+
// negative conditions' evidence is produced, and token creation is now one of
|
|
21
|
+
// them — it is the cheapest place in the system to catch a mode built with poor
|
|
22
|
+
// contrast, because the mode is being defined right then and nothing is built
|
|
23
|
+
// on it yet. Its subject is the whole colour system, never a component.
|
|
24
|
+
export const STAGES = [
|
|
25
|
+
'component-builder',
|
|
26
|
+
'storybook-chromatic-builder',
|
|
27
|
+
'token-sync-layer',
|
|
28
|
+
'token-builder',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Which stages are keyed by component (subject = component name) rather than
|
|
32
|
+
// by the system as a whole (subject = "system"). This is the machine-readable
|
|
33
|
+
// copy of the split `references/proof-bundle.md` documents in prose — it is
|
|
34
|
+
// the constant `checkProof` asks "is this stage keyed by component?".
|
|
35
|
+
export const PER_COMPONENT_STAGES = new Set([
|
|
36
|
+
'component-builder',
|
|
37
|
+
'storybook-chromatic-builder',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// Tells the reader (Step 6's `checkProof`) how to compare a recorded derived
|
|
41
|
+
// check against a rerun: a check scoped to "component" is compared per
|
|
42
|
+
// subject; one scoped to "system" is compared once, system-wide, regardless
|
|
43
|
+
// of which subject it was recorded under.
|
|
44
|
+
export const DERIVED_RULE_SCOPE = {
|
|
45
|
+
'orphan-token': 'system',
|
|
46
|
+
'state-incomplete': 'component',
|
|
47
|
+
'name-drift': 'component',
|
|
48
|
+
// Contrast is a property of the token system as a whole, not of any one
|
|
49
|
+
// component, so a recorded result is compared against the rerun system-wide —
|
|
50
|
+
// the same scope orphan-token has.
|
|
51
|
+
'color-contrast': 'system',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// The names this stage has not yet proven, per the Adoption decision's
|
|
55
|
+
// capture-once-then-shrink rule. A missing `exempt` key is deliberately read
|
|
56
|
+
// as "exempts nothing", not "exempts everything": the only way to reach a
|
|
57
|
+
// stage entry with no `exempt` key is a hand-edited manifest (record mode
|
|
58
|
+
// always writes one for a per-component stage), and the off-switch reading
|
|
59
|
+
// would let a hand-edit silently stop the gate. A missing key must get
|
|
60
|
+
// louder, not quieter.
|
|
61
|
+
export function stageExempt(manifest, stage) {
|
|
62
|
+
return new Set(manifest.verification?.stages?.[stage]?.exempt ?? []);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The human-readable "when did this system opt in", written once by record
|
|
66
|
+
// mode and never rewritten. No gate reads it.
|
|
67
|
+
//
|
|
68
|
+
// It is not compared against `components.meta[name].updatedAt`, and `exempt`
|
|
69
|
+
// is a captured list of names rather than a date comparison, for the same
|
|
70
|
+
// reason: `updatedAt` has a second writer.
|
|
71
|
+
// `storybook-chromatic-builder` refreshes it on promotion
|
|
72
|
+
// (`skills/storybook-chromatic-builder/SKILL.md:276-277`), a different stage
|
|
73
|
+
// from the one that owes the entry, so a date gate would fail a pre-existing
|
|
74
|
+
// component the first time an unrelated storying run promotes it.
|
|
75
|
+
//
|
|
76
|
+
// Neither `adoptedAt` nor `exempt` is derived from the stage file's entries,
|
|
77
|
+
// either: `mergeEntry` is latest-wins and replaces a subject's `at`, so "the
|
|
78
|
+
// earliest `at` in the file" would slide forward every time the earliest
|
|
79
|
+
// subject is rebuilt.
|
|
80
|
+
export function stageAdoptedAt(manifest, stage) {
|
|
81
|
+
return manifest.verification?.stages?.[stage]?.adoptedAt ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function stagePath(root, stage) {
|
|
85
|
+
return join(root, PROOF_DIR, `${stage}.json`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function loadStage(root, stage) {
|
|
89
|
+
const path = stagePath(root, stage);
|
|
90
|
+
if (!existsSync(path)) return null;
|
|
91
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function stageFingerprint(stageFile) {
|
|
95
|
+
return fingerprint(stableStringify(stageFile));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const CHECK_METHODS = ['derived', 'attested'];
|
|
99
|
+
const CHECK_RESULTS = ['pass', 'fail'];
|
|
100
|
+
|
|
101
|
+
export function entryProblems(entry) {
|
|
102
|
+
const problems = [];
|
|
103
|
+
if (typeof entry.at !== 'string' || entry.at === '') {
|
|
104
|
+
problems.push('"at" must be a non-empty string');
|
|
105
|
+
}
|
|
106
|
+
if (typeof entry.advancedBecause !== 'string' || entry.advancedBecause === '') {
|
|
107
|
+
problems.push('"advancedBecause" must be a non-empty string');
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(entry.changed) || !entry.changed.every((c) => typeof c === 'string')) {
|
|
110
|
+
problems.push('"changed" must be an array of strings');
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(entry.checks) || entry.checks.length === 0) {
|
|
113
|
+
problems.push('"checks" must be a non-empty array');
|
|
114
|
+
} else {
|
|
115
|
+
entry.checks.forEach((check, i) => {
|
|
116
|
+
if (!check || typeof check !== 'object') {
|
|
117
|
+
problems.push(`checks[${i}] must be an object`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (typeof check.name !== 'string' || check.name === '') {
|
|
121
|
+
problems.push(`checks[${i}].name must be a non-empty string`);
|
|
122
|
+
}
|
|
123
|
+
if (!CHECK_METHODS.includes(check.method)) {
|
|
124
|
+
problems.push(`checks[${i}].method must be one of ${CHECK_METHODS.join(', ')}`);
|
|
125
|
+
}
|
|
126
|
+
if (!CHECK_RESULTS.includes(check.result)) {
|
|
127
|
+
problems.push(`checks[${i}].result must be one of ${CHECK_RESULTS.join(', ')}`);
|
|
128
|
+
}
|
|
129
|
+
if (typeof check.evidence !== 'string') {
|
|
130
|
+
problems.push(`checks[${i}].evidence must be a string`);
|
|
131
|
+
}
|
|
132
|
+
// Optional, and only `contrast-baseline` uses it today — but validated
|
|
133
|
+
// because it is load-bearing: token-sync-layer subtracts these (fg, bg,
|
|
134
|
+
// mode) triples from its own gate run's color-contrast failures, so a
|
|
135
|
+
// malformed one is the difference between a pair the user accepted and a
|
|
136
|
+
// pair nobody has seen. Reject it here rather than letting the sync
|
|
137
|
+
// silently match nothing.
|
|
138
|
+
if (check.accepted !== undefined) {
|
|
139
|
+
if (!Array.isArray(check.accepted)) {
|
|
140
|
+
problems.push(`checks[${i}].accepted must be an array when present`);
|
|
141
|
+
} else {
|
|
142
|
+
check.accepted.forEach((pair, j) => {
|
|
143
|
+
const wellFormed =
|
|
144
|
+
pair &&
|
|
145
|
+
typeof pair === 'object' &&
|
|
146
|
+
['fg', 'bg', 'mode'].every((k) => typeof pair[k] === 'string' && pair[k] !== '');
|
|
147
|
+
if (!wellFormed) {
|
|
148
|
+
problems.push(
|
|
149
|
+
`checks[${i}].accepted[${j}] must carry non-empty "fg", "bg" and "mode" strings`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (entry.screenshot !== undefined && typeof entry.screenshot !== 'string') {
|
|
158
|
+
problems.push('"screenshot" must be a string when present');
|
|
159
|
+
}
|
|
160
|
+
return problems;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function mergeEntry(stageFile, stage, subject, entry) {
|
|
164
|
+
const subjects = { ...(stageFile?.subjects ?? {}), [subject]: entry };
|
|
165
|
+
const sortedSubjects = {};
|
|
166
|
+
for (const key of Object.keys(subjects).sort()) {
|
|
167
|
+
sortedSubjects[key] = subjects[key];
|
|
168
|
+
}
|
|
169
|
+
return { stage, subjects: sortedSubjects };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function recordedChecks(stageFile) {
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const [subject, entry] of Object.entries(stageFile.subjects ?? {})) {
|
|
175
|
+
for (const check of entry.checks ?? []) {
|
|
176
|
+
out.push({
|
|
177
|
+
subject,
|
|
178
|
+
name: check.name,
|
|
179
|
+
method: check.method,
|
|
180
|
+
result: check.result,
|
|
181
|
+
evidence: check.evidence,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|