@radicool/throughline 0.19.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/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 +3 -2
- package/scripts/lib/proof.mjs +186 -0
- package/scripts/lib/source-scan.mjs +45 -0
- package/scripts/validate-adherence.mjs +895 -0
- package/scripts/validate-token-output.mjs +468 -63
- package/scripts/verify-check.mjs +710 -0
package/scripts/README.md
CHANGED
|
@@ -10,9 +10,11 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
10
10
|
| `validate-crosswalk.mjs` | Resolve every `newToken` against the DTCG token source; assert resolved value == `newValue`, N/N. The CI gate. | `tokens:validate` |
|
|
11
11
|
| `build-reverse-index.mjs` | Emit a `codeToken -> newToken` map from the crosswalk to semi-automate SCSS/Tailwind swaps. | `tokens:reverse-index` |
|
|
12
12
|
| `guard-token-removal.mjs` | Grep `.ts/.tsx` (minus generated + tests) for about-to-be-deleted symbols; blocks cleanup until zero references remain. | run during the cleanup phase |
|
|
13
|
-
| `validate-token-output.mjs` | Assert generated
|
|
13
|
+
| `validate-token-output.mjs` | Assert generated token output — Swift, Kotlin, or web CSS custom properties — matches its DTCG source: authored-unit fidelity and no mode collisions everywhere, plus no leaked CSS syntax and no bare unit literals in Swift and Kotlin. Fails when no emitted symbol matches a source token, and reports match rate, unparsed lines, and unemitted tokens on every run. | `tokens:validate-output` |
|
|
14
|
+
| `validate-adherence.mjs` | Assert the code *consuming* a design system still adheres to it: every referenced component exists, every literal variant value is one the system declares, and no colour, spacing, radius or type literal duplicates a token that already holds that value. Fails when an enabled rule had nothing to check, so a green run always means something was verified. | `adherence:check` |
|
|
15
|
+
| `lib/source-scan.mjs` | Shared source-tree walker (`walk`, `DEFAULT_EXCLUDES`, `SOURCE_EXT`) plus `normalizeName`, the display-name-to-code-identifier fold. Every gate that scans a consumer's repo reads it from here rather than carrying its own copy. | copied alongside `guard-token-removal.mjs`, and in the documentation set for `validate-adherence.mjs` and `verify-check.mjs` |
|
|
14
16
|
| `lib/crosswalk.mjs` | Shared loader + structural validation for `crosswalk.json` (used by the validator and reverse-index). | copied alongside |
|
|
15
|
-
| `lib/dtcg.mjs` | Shared DTCG flatten + `{alias}` resolution. Dual-node aware: a node carrying both a `$value` and children yields its own value **and** is descended into. Used by `validate-crosswalk.mjs
|
|
17
|
+
| `lib/dtcg.mjs` | Shared DTCG flatten + `{alias}` resolution. Dual-node aware: a node carrying both a `$value` and children yields its own value **and** is descended into. Used by `validate-crosswalk.mjs`, `validate-token-output.mjs`, `validate-adherence.mjs`, `verify-check.mjs`, and `lib/sd-native.mjs`. | copied alongside each of those |
|
|
16
18
|
| `lib/sd-native.mjs` | The Style Dictionary native configuration as code: unit-aware dimension transforms, `color-mix` computation, dual-node preprocessing, platform assembly, and a per-mode source guard. Style Dictionary is a parameter, never an import. | copied alongside `validate-token-output.mjs` |
|
|
17
19
|
| `lib/native-literal.mjs` | Shared grammar for "is this a well-formed Swift or Kotlin literal": parses rather than pattern-matches, so an unquoted string, a raw CSS function, or any other unanticipated case fails the same way. Used by `sd-native.mjs`'s output filter and by `validate-token-output.mjs`'s invalid-literal rule. | copied alongside `validate-token-output.mjs` and `lib/sd-native.mjs` |
|
|
18
20
|
| `crosswalk.schema.json` | The finalized JSON Schema for `crosswalk.json` (contract + editor support). | copied beside `crosswalk.json` |
|
|
@@ -22,6 +24,10 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
22
24
|
| `lib/doc-record.mjs` | Canonical record load + `canonicalFingerprint` (sha256 over the record minus `provenance`). The fingerprint every surface is stamped with. | copied alongside docs-check.mjs |
|
|
23
25
|
| `lib/doc-card-render.figma.js` | Figma renderer template for the doc card's `Usage` band and header. Inlined into `references/doc-card-builder.md`; never executed as a module. | plugin-internal (not installed) |
|
|
24
26
|
| `lib/doc-card-plan.mjs` | Pure layout planner for the doc card's `Usage` band + `DOC_CARD_RENDERER_VERSION` (single source of the layout version). Inlined into `references/doc-card-builder.md`; imported by `docs-check.mjs`. | copied alongside docs-check.mjs; also inlined into the generated builder |
|
|
27
|
+
| `verify-check.mjs` | The verification proof bundle: records each stage's entry, and re-derives what it can rather than trusting the record. Fails on an orphaned token, a component documented without its archetype's baseline interaction states, a name that drifts between the manifest, its record and its code surface, or a recorded result the rerun contradicts. | `verify:check` |
|
|
28
|
+
| `lib/proof.mjs` | The proof store's vocabulary and mechanics: the stage list, entry validation, latest-wins merge, and the fingerprint the manifest pointer carries. | copied alongside `verify-check.mjs` |
|
|
29
|
+
| `lib/component-states.mjs` | The archetype table and resolver behind `state-incomplete` — which baseline interaction states each kind of component owes, and when to abstain. | copied alongside `verify-check.mjs` |
|
|
30
|
+
| `lib/contrast.mjs` | The WCAG contrast maths and the semantic colour pairs behind `color-contrast` — which text role must clear which surface, in every mode, and where the rule deliberately abstains. | copied alongside `verify-check.mjs` |
|
|
25
31
|
| `build-doc-card-builder.mjs` | Generate `references/doc-card-builder.md` from the planner + the Figma renderer template (`lib/doc-card-render.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
|
|
26
32
|
| `build-native-adapter-config.mjs` | Generate `references/native-adapter-config.md` by slicing `lib/sd-native.mjs` on its `@doc-section` markers and interleaving each fragment under its prose. Fails when module code falls outside every section, so the doc cannot silently ship incomplete. `--check` gates CI. | plugin-internal (not installed) |
|
|
27
33
|
|
|
@@ -29,7 +35,7 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
29
35
|
registering them leaves a repo with a script on disk and no entry point, which
|
|
30
36
|
is how a stale `docs:check` went unnoticed for a full release. Both
|
|
31
37
|
`storybook-chromatic-builder` (first-time setup) and `/document-component`
|
|
32
|
-
(freshness refresh) install the same
|
|
38
|
+
(freshness refresh) install the same twelve files and register the same five
|
|
33
39
|
scripts:
|
|
34
40
|
|
|
35
41
|
| File | npm script |
|
|
@@ -39,9 +45,29 @@ scripts:
|
|
|
39
45
|
| `docs-lint.mjs` | `"docs:lint": "node scripts/docs-lint.mjs"` |
|
|
40
46
|
| `lib/doc-record.mjs` | — (imported by the above) |
|
|
41
47
|
| `lib/doc-card-plan.mjs` | — (imported by the above) |
|
|
48
|
+
| `validate-adherence.mjs` | `"adherence:check": "node scripts/validate-adherence.mjs --root ../../apps --system ../.. --package <specifier> --tokens dtcg/tokens.json"` |
|
|
49
|
+
| `lib/source-scan.mjs` | — (imported by the above) |
|
|
50
|
+
| `lib/dtcg.mjs` | — (imported by the above) |
|
|
51
|
+
| `verify-check.mjs` | `"verify:check": "node scripts/verify-check.mjs --root ../.. --tokens dtcg/tokens.json"` |
|
|
52
|
+
| `lib/proof.mjs` | — (imported by the above) |
|
|
53
|
+
| `lib/component-states.mjs` | — (imported by the above) |
|
|
54
|
+
| `lib/contrast.mjs` | — (imported by the above) |
|
|
42
55
|
|
|
43
56
|
A refresh that adds a file must also add its npm script; check `package.json`
|
|
44
|
-
for all
|
|
57
|
+
for all five npm scripts every time, not just the file that changed.
|
|
58
|
+
|
|
59
|
+
`verify:check`, like `adherence:check`, carries placeholder paths the installing
|
|
60
|
+
skill substitutes for the repo's real layout. Dropping `--tokens` rather than
|
|
61
|
+
substituting it is how a repo ends up with a rule that never runs: with no token
|
|
62
|
+
source there is nothing to read, so the gate reports both `orphan-token` and
|
|
63
|
+
`color-contrast` as skipped on every run — two stop conditions disabled by their
|
|
64
|
+
own registration.
|
|
65
|
+
|
|
66
|
+
The registered form carries a single `dtcg/tokens.json`, and a system whose
|
|
67
|
+
values span modes registers **one `--tokens` per mode file**, because each source
|
|
68
|
+
file is its own mode. Registering a multi-mode system with a single `--tokens`
|
|
69
|
+
flag leaves a script that checks Light and reports that it checked one mode —
|
|
70
|
+
quieter than the dropped `--tokens` failure above it, and just as wrong.
|
|
45
71
|
|
|
46
72
|
The crosswalk contract is documented in
|
|
47
73
|
`${CLAUDE_PLUGIN_ROOT}/references/crosswalk-schema.md`.
|
|
@@ -52,15 +78,123 @@ The crosswalk contract is documented in
|
|
|
52
78
|
node validate-crosswalk.mjs --crosswalk crosswalk.json --tokens dtcg/tokens.json
|
|
53
79
|
node build-reverse-index.mjs --crosswalk crosswalk.json --out crosswalk.reverse.json
|
|
54
80
|
node guard-token-removal.mjs --root . --symbols symbols-to-remove.txt
|
|
81
|
+
node validate-adherence.mjs --root ../../apps --system ../.. --package @acme/ui --tokens dtcg/tokens.json
|
|
82
|
+
node validate-token-output.mjs --source dtcg/primitives.json --source dtcg/semantic.dark.json --output css/tokens.css --platform shadcn --block .dark --min-match 1
|
|
83
|
+
node verify-check.mjs --root ../.. --tokens dtcg/tokens.json
|
|
84
|
+
node verify-check.mjs --record --stage component-builder --subject Button --entry entry.json
|
|
55
85
|
```
|
|
56
86
|
|
|
87
|
+
`validate-adherence.mjs` takes every path explicitly because cwd is the package
|
|
88
|
+
holding the script, not the repo root. `--system` has no default: defaulting it
|
|
89
|
+
to `--root` resolves to a path that does not exist. `--tokens` is repeatable, so
|
|
90
|
+
a system whose values span mode files passes each one. An alias resolves against
|
|
91
|
+
every `--tokens` file, so a semantic file can point at primitives in another, and
|
|
92
|
+
the report's `colour:` line counts the colour tokens it skipped as unresolvable or
|
|
93
|
+
non-hex. Files inside the package
|
|
94
|
+
that owns a `--tokens` file are not scanned when that package sits beneath
|
|
95
|
+
`--root`, and the report prints an `excluded:` line naming it. A tag whose name
|
|
96
|
+
starts with a built component's name and continues with a capital letter
|
|
97
|
+
(`CardTitle` for `Card`) is read as part of that component, and the report lists
|
|
98
|
+
each one on a `parts:` line. `--skip <rule>`
|
|
99
|
+
switches a rule off entirely (a name that isn't a rule exits `2`, rather than
|
|
100
|
+
skipping nothing), and a skipped rule is absent rather than inert, which
|
|
101
|
+
is the supported answer for a repo the rule cannot apply to (a Vue, Svelte or
|
|
102
|
+
Angular app has no JSX for the component rules to read). An Angular `.html`
|
|
103
|
+
template is read through its styling attributes only: `style`, `class`,
|
|
104
|
+
`ngStyle`, `ngClass`, the SVG colour attributes (`fill`, `stroke`, `stop-color`,
|
|
105
|
+
`flood-color`, `lighting-color`, `color`), and their bindings (`[attr.fill]`,
|
|
106
|
+
`[style.color]`). Text content, `data-*`, `href`, comments and unquoted values
|
|
107
|
+
are not read. Colour is read from all of these. Dimensions are read only where a
|
|
108
|
+
property name sits beside the value: `prop: value` in `style` or `ngStyle`, and
|
|
109
|
+
an arbitrary-value class like `p-[12px]`. `[style.padding.px]="12"` is not.
|
|
110
|
+
`--skip token-exists-for-dimension`
|
|
111
|
+
is the answer for a system with no spacing, radius or type tokens, which
|
|
112
|
+
otherwise fails as `dimension-rule-inert`.
|
|
113
|
+
|
|
114
|
+
`validate-token-output.mjs` reads web output as CSS custom properties for
|
|
115
|
+
`--platform shadcn`, `tailwind` and `vanilla-css`. `mui` exits `2`: a MUI theme,
|
|
116
|
+
like a Tailwind v3 JavaScript config, has no custom properties to read, and
|
|
117
|
+
isn't checked yet (#127). Web puts every mode in one file, so run the gate once
|
|
118
|
+
per mode block, passing the sources that block was built from. `--block` names
|
|
119
|
+
the block by its enclosing at-rules and selector, joined by single spaces:
|
|
120
|
+
`:root`, `.dark`, `[data-theme="light"]`, `@media (min-width: 768px) :root`,
|
|
121
|
+
`@theme inline`. A file with one block needs no `--block`. A file with several,
|
|
122
|
+
or a `--block` that isn't there, exits `2` and lists every block with its
|
|
123
|
+
declaration count, so the exact key is in the error. A build split across files
|
|
124
|
+
passes each one as another `--output`: everything declared in any of them counts
|
|
125
|
+
as declared, and a token declared in any block counts as emitted. Six rules fail
|
|
126
|
+
a web run:
|
|
127
|
+
|
|
128
|
+
- `unit-fidelity` — the value changed magnitude, or a ratio gained a unit, or a
|
|
129
|
+
length lost one (`16` → `16rem`, `1.1` → `1.1rem`, `16px` → `16`). `16` →
|
|
130
|
+
`1rem` passes.
|
|
131
|
+
- `reference-fidelity` — a declaration's `var()`s don't name the tokens its
|
|
132
|
+
source references.
|
|
133
|
+
- `dangling-reference` — a `var()` to a variable no `--output` declares, unless
|
|
134
|
+
the source wrote that `var()` itself.
|
|
135
|
+
- `no-unresolved-reference` — a `{reference}` reached the CSS unresolved.
|
|
136
|
+
- `invalid-value` — `[object Object]`, `NaN` or `undefined` was written into the
|
|
137
|
+
CSS.
|
|
138
|
+
- `unverifiable-dimension` — a dimension came out in a form the gate can't
|
|
139
|
+
compare, so it was never checked.
|
|
140
|
+
|
|
141
|
+
Nothing fails for being CSS: `var()`, `calc()`, `color-mix()` and units are all
|
|
142
|
+
legal output. On `shadcn` and `tailwind`, a declaration with no source token
|
|
143
|
+
whose whole value is a `var()` to a declared variable is an alias layer
|
|
144
|
+
(`--background: var(--color-bg-canvas)`). The report counts aliases on their own
|
|
145
|
+
line and leaves them out of the match rate, so `--min-match 1` still holds.
|
|
146
|
+
|
|
147
|
+
`verify-check.mjs` has two modes. The **gate** — `--root <dir>`, repeatable
|
|
148
|
+
`--tokens` and `--source`, repeatable `--skip` — reads the proof store at
|
|
149
|
+
`design-system/proof/`, re-derives every check a stage recorded as `derived`, and
|
|
150
|
+
fails on `orphan-token`, `state-incomplete`, `name-drift`, `color-contrast`,
|
|
151
|
+
`proof-missing`, `proof-stale`, `proof-contradicted`, `nothing-verified`,
|
|
152
|
+
`orphan-rule-inert` and `contrast-rule-inert`.
|
|
153
|
+
`archetype-unknown` and `proof-unadopted` are informational. With no `--source`
|
|
154
|
+
the walk covers `--root`; the package that owns a `--tokens` file is set aside
|
|
155
|
+
either way, and the report prints an `excluded:` line naming it — without that,
|
|
156
|
+
generated token output binds every semantic token to itself and `orphan-token`
|
|
157
|
+
can never fire. The **recorder** — `--record --stage <name> [--subject <Name>]
|
|
158
|
+
--entry <file.json>` — merges one entry into that stage's file, recomputes its
|
|
159
|
+
fingerprint and stamps the manifest's `verification` pointer. Skills call it;
|
|
160
|
+
nothing hand-writes the store or hand-computes a hash.
|
|
161
|
+
|
|
162
|
+
`--skip <rule>` switches a rule off entirely, and a skipped rule is absent rather
|
|
163
|
+
than inert. A name that isn't one of the four rules exits `2`: `colour-contrast`
|
|
164
|
+
is a typo here, not a skip. With no `--tokens` there is no token source to read, so
|
|
165
|
+
`orphan-token` and `color-contrast` are reported as skipped rather than passed.
|
|
166
|
+
|
|
167
|
+
`color-contrast` compares the semantic colour pairs the system's own role
|
|
168
|
+
definitions already promise — `text/onEmphasis` over `bg/emphasis`, each status
|
|
169
|
+
text over its own status background, and so on. Each `--tokens` file is one
|
|
170
|
+
**mode**, and a pair has to clear WCAG AA's 4.5:1 for normal text in every one of
|
|
171
|
+
them: a system that passes in Light and fails in Dark fails. A pair whose roles
|
|
172
|
+
are absent from a mode abstains rather than passing, which is why a run that
|
|
173
|
+
recognised no role anywhere fails as `contrast-rule-inert` instead of reporting a
|
|
174
|
+
clean sweep. `text/disabled` is excluded, because WCAG 1.4.3 exempts inactive
|
|
175
|
+
components and a disabled role is deliberately low-contrast. A translucent
|
|
176
|
+
foreground is composited over its background and compared; a translucent
|
|
177
|
+
background is skipped and counted, because what sits behind it is a layout fact
|
|
178
|
+
the token tier does not hold. Every number on the report's `contrast:` line is a
|
|
179
|
+
pair, in the mode it occurred in. `--skip color-contrast` is the escape hatch for
|
|
180
|
+
a system whose roles are named differently. The orphan rule counts
|
|
181
|
+
a token as bound when its normalized key appears anywhere in a scanned file, so
|
|
182
|
+
`color.bg.primary` is bound by a mention of `color.bg.primary.hover`: every miss
|
|
183
|
+
is an orphan reported as bound, never a correct system failed. A `derived` check
|
|
184
|
+
is one the gate recomputes off disk, and its recorded result is a cache that must
|
|
185
|
+
agree — a disagreement is `proof-contradicted`. An `attested` check is an agent's
|
|
186
|
+
live observation of something the CLI cannot read, printed informationally and
|
|
187
|
+
never the reason a run passes. See
|
|
188
|
+
`${CLAUDE_PLUGIN_ROOT}/references/proof-bundle.md`.
|
|
189
|
+
|
|
57
190
|
Exit codes: `0` success, `1` validation/guard failure (mismatch, missing token,
|
|
58
191
|
conflict, or remaining reference), `2` bad CLI arguments.
|
|
59
192
|
|
|
60
193
|
## How the skill installs these
|
|
61
194
|
|
|
62
195
|
`token-crosswalk-builder` copies `lib/crosswalk.mjs`, `lib/dtcg.mjs`,
|
|
63
|
-
`
|
|
196
|
+
`lib/source-scan.mjs`, `validate-crosswalk.mjs`, `build-reverse-index.mjs`,
|
|
197
|
+
`guard-token-removal.mjs`, and
|
|
64
198
|
`crosswalk.schema.json` into the user's `packages/tokens/scripts/` (schema beside `crosswalk.json`), then
|
|
65
199
|
wires `packages/tokens/package.json`:
|
|
66
200
|
|
|
@@ -88,7 +222,7 @@ Run the suite from the repo root (no install step — uses only Node built-ins):
|
|
|
88
222
|
node --test
|
|
89
223
|
```
|
|
90
224
|
|
|
91
|
-
This auto-discovers every `**/*.test.mjs` recursively
|
|
225
|
+
This auto-discovers every `**/*.test.mjs` recursively. Don't use
|
|
92
226
|
`node --test scripts/` — a directory positional is treated as a test name on
|
|
93
227
|
Node >=21 and errors; a `scripts/*.test.mjs` glob silently skips `scripts/lib/`.
|
|
94
228
|
|
|
@@ -130,7 +130,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
130
130
|
});
|
|
131
131
|
if (process.argv.includes('--check')) {
|
|
132
132
|
let onDisk = null;
|
|
133
|
-
try { onDisk = readFileSync(OUT, 'utf8'); } catch
|
|
133
|
+
try { onDisk = readFileSync(OUT, 'utf8'); } catch { /* missing counts as drift */ }
|
|
134
134
|
if (onDisk !== result) {
|
|
135
135
|
console.error('✗ references/doc-card-builder.md out of date; run: node scripts/build-doc-card-builder.mjs');
|
|
136
136
|
process.exit(1);
|
|
@@ -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,6 +1,7 @@
|
|
|
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
|
|