@radicool/throughline 0.20.0 → 0.21.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 +1 -1
- package/adapters/codex/prompts/component-builder.md +7 -3
- package/adapters/codex/prompts/storybook-chromatic-builder.md +1 -1
- package/adapters/codex/prompts/token-builder.md +2 -2
- package/adapters/codex/prompts/token-sync-layer.md +1 -1
- package/adapters/cursor/.cursor/rules/component-builder.mdc +7 -3
- package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +1 -1
- package/adapters/cursor/.cursor/rules/token-builder.mdc +2 -2
- package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +1 -1
- package/adapters/generic/skills/component-builder/SKILL.md +7 -3
- package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +1 -1
- package/adapters/generic/skills/token-builder/SKILL.md +2 -2
- package/adapters/generic/skills/token-sync-layer/SKILL.md +1 -1
- package/package.json +1 -1
- package/references/figma-component-standards.md +47 -0
- package/references/proof-bundle.md +7 -0
- package/scripts/README.md +14 -12
- package/scripts/build-docs-digest.mjs +1 -0
- package/scripts/validate-adherence.mjs +289 -6
- package/scripts/verify-check.mjs +8 -0
package/README.md
CHANGED
|
@@ -204,7 +204,7 @@ Future improvements and planned capabilities. Have a request? **[Open an issue](
|
|
|
204
204
|
- **Deeper Code Connect coverage** — richer Figma-to-code mappings as more plans support it.
|
|
205
205
|
- **Expanded component starters** — a larger foundational kit out of the box.
|
|
206
206
|
- **Richer status & auditing** — more from `/design-system-status`, including drift detection between Figma and code.
|
|
207
|
-
- **Built-in accessibility checks** — the color half is shipped. Eight semantic text-on-surface pairs are checked against WCAG AA's 4.5:1 when tokens are created in Figma, and derived again from the token source when they sync to code — in every mode, so a system that clears in light and fails in dark fails. A failing pair stops the build rather than warning.
|
|
207
|
+
- **Built-in accessibility checks** — the color half is shipped. Eight semantic text-on-surface pairs are checked against WCAG AA's 4.5:1 when tokens are created in Figma, and derived again from the token source when they sync to code — in every mode, so a system that clears in light and fails in dark fails. A failing pair stops the build rather than warning. Components are checked too: on web frameworks, a Figma build stops when a focus state has no visible ring, and in JSX code the adherence gate fails an icon-only button with no accessible name and a status badge that relies on color alone. Still ahead: focus-ring contrast (WCAG's 3:1 for non-text).
|
|
208
208
|
- **Token fan-out to more platforms** — the DTCG token source is platform-neutral, and web targets are in daily use. Every target is **validated per build, not assumed**: `tokens:validate-output` checks generated output against its source, native Swift and Kotlin as well as web CSS, because the stock transforms have been measured emitting wrong-but-compiling values. iOS/Swift is a curated adapter whose Style Dictionary configuration ships as tested code, and Android/Kotlin uses the same configuration through the Tier 2 protocol. JavaScript theme output (MUI, a Tailwind v3 config) isn't checked yet.
|
|
209
209
|
- **Native component code generation** — producing a SwiftUI view or a Compose composable the way Storybook components are produced for React. This does not exist yet; it is a separate, larger effort than token fan-out, and the two were previously described as one roadmap item.
|
|
210
210
|
|
|
@@ -171,7 +171,9 @@ deterministic naming):
|
|
|
171
171
|
visual-validation loop (screenshot → fix any overlaps/misalignment →
|
|
172
172
|
re-screenshot) **and its "Post-build audit (REQUIRED before handoff)"
|
|
173
173
|
read-back checklist** (container type, auto layout, bound variables,
|
|
174
|
-
deterministic names) before the checkpoint.
|
|
174
|
+
deterministic names) before the checkpoint. When you build inline, with no
|
|
175
|
+
`figma-executor`, that includes item 9's `focus-indicator` check: a miss stops
|
|
176
|
+
the build, as it would for the executor.
|
|
175
177
|
|
|
176
178
|
Checkpoint after each component: show all variants, confirm before the next.
|
|
177
179
|
|
|
@@ -357,8 +359,10 @@ any component built with the toggle + manual-swap fallback is listed in
|
|
|
357
359
|
**Record the stage entry, one call per component that actually finished:**
|
|
358
360
|
`node .throughline/scripts/verify-check.mjs --record --stage
|
|
359
361
|
component-builder --subject <Name> --entry <tmp>.json`. Build `<tmp>.json` with
|
|
360
|
-
`changed` (what was built), the attested checks `structural-read-back`,
|
|
361
|
-
`state-baseline` and `figma-name` from what the `figma-executor` returned
|
|
362
|
+
`at` (an ISO timestamp you supply), `changed` (what was built), the attested checks `structural-read-back`,
|
|
363
|
+
`state-baseline` and `figma-name` from what the `figma-executor` returned (or
|
|
364
|
+
what your inline read-back found), `focus-indicator` when that check ran and
|
|
365
|
+
passed (not when it was skipped),
|
|
362
366
|
`review` when a reviewer ran, `screenshot` when one was captured, and
|
|
363
367
|
`advancedBecause`. The entry shape is
|
|
364
368
|
`.throughline/references/proof-bundle.md` — don't restate it here. Write
|
|
@@ -384,7 +384,7 @@ component-pipeline orchestrator; token changes flow through `/sync-figma-tokens`
|
|
|
384
384
|
**Record the stage entry, one call per component finalized this run:**
|
|
385
385
|
`node .throughline/scripts/verify-check.mjs --record --stage
|
|
386
386
|
storybook-chromatic-builder --subject <Name> --entry <tmp>.json`. Build
|
|
387
|
-
`<tmp>.json` with the attested checks `build` (the story build outcome) and
|
|
387
|
+
`<tmp>.json` with `at` (an ISO timestamp you supply), the attested checks `build` (the story build outcome) and
|
|
388
388
|
`review`, `advancedBecause`, and **the two component-scoped derived results,
|
|
389
389
|
`state-incomplete` and `name-drift`, read off the `verify:check` report Step
|
|
390
390
|
5.5 already ran** — recorded with `method: "derived"`. This is one of two stages
|
|
@@ -424,8 +424,8 @@ groups were created in `tokens.styleGroups`. Append `token-builder` to
|
|
|
424
424
|
|
|
425
425
|
**Record the stage entry:** `node .throughline/scripts/verify-check.mjs
|
|
426
426
|
--record --stage token-builder --entry <tmp>.json` (subject defaults to
|
|
427
|
-
`"system"`; this stage is not per-component). Build `<tmp>.json` with `
|
|
428
|
-
summarizing the collections and modes created, the attested check
|
|
427
|
+
`"system"`; this stage is not per-component). Build `<tmp>.json` with `at` (an ISO
|
|
428
|
+
timestamp you supply), `changed` summarizing the collections and modes created, the attested check
|
|
429
429
|
`contrast-baseline` carrying `result` and a one-line `evidence` naming the
|
|
430
430
|
tightest ratio per mode (`"Light 5.2:1, Dark 4.9:1 — tightest is
|
|
431
431
|
text/onEmphasis on bg/emphasis"`), and `advancedBecause`.
|
|
@@ -359,7 +359,7 @@ Update the manifest: `sync.lastRun` (timestamp), `tokens.lastSync`, confirm
|
|
|
359
359
|
Alongside that, record the stage entry: `node
|
|
360
360
|
.throughline/scripts/verify-check.mjs --record --stage
|
|
361
361
|
token-sync-layer --entry <tmp>.json` (subject defaults to `"system"`, since this
|
|
362
|
-
stage isn't per-component). Build `<tmp>.json` with `changed` summarizing tokens
|
|
362
|
+
stage isn't per-component). Build `<tmp>.json` with `at` (an ISO timestamp you supply), `changed` summarizing tokens
|
|
363
363
|
added, changed, deleted and probable renames, and the attested
|
|
364
364
|
`tokens:validate-output` result as a check, plus `advancedBecause`. The entry
|
|
365
365
|
shape is `.throughline/references/proof-bundle.md` — don't restate it
|
|
@@ -175,7 +175,9 @@ deterministic naming):
|
|
|
175
175
|
visual-validation loop (screenshot → fix any overlaps/misalignment →
|
|
176
176
|
re-screenshot) **and its "Post-build audit (REQUIRED before handoff)"
|
|
177
177
|
read-back checklist** (container type, auto layout, bound variables,
|
|
178
|
-
deterministic names) before the checkpoint.
|
|
178
|
+
deterministic names) before the checkpoint. When you build inline, with no
|
|
179
|
+
`figma-executor`, that includes item 9's `focus-indicator` check: a miss stops
|
|
180
|
+
the build, as it would for the executor.
|
|
179
181
|
|
|
180
182
|
Checkpoint after each component: show all variants, confirm before the next.
|
|
181
183
|
|
|
@@ -361,8 +363,10 @@ any component built with the toggle + manual-swap fallback is listed in
|
|
|
361
363
|
**Record the stage entry, one call per component that actually finished:**
|
|
362
364
|
`node .throughline/scripts/verify-check.mjs --record --stage
|
|
363
365
|
component-builder --subject <Name> --entry <tmp>.json`. Build `<tmp>.json` with
|
|
364
|
-
`changed` (what was built), the attested checks `structural-read-back`,
|
|
365
|
-
`state-baseline` and `figma-name` from what the `figma-executor` returned
|
|
366
|
+
`at` (an ISO timestamp you supply), `changed` (what was built), the attested checks `structural-read-back`,
|
|
367
|
+
`state-baseline` and `figma-name` from what the `figma-executor` returned (or
|
|
368
|
+
what your inline read-back found), `focus-indicator` when that check ran and
|
|
369
|
+
passed (not when it was skipped),
|
|
366
370
|
`review` when a reviewer ran, `screenshot` when one was captured, and
|
|
367
371
|
`advancedBecause`. The entry shape is
|
|
368
372
|
`.throughline/references/proof-bundle.md` — don't restate it here. Write
|
|
@@ -388,7 +388,7 @@ component-pipeline orchestrator; token changes flow through `/sync-figma-tokens`
|
|
|
388
388
|
**Record the stage entry, one call per component finalized this run:**
|
|
389
389
|
`node .throughline/scripts/verify-check.mjs --record --stage
|
|
390
390
|
storybook-chromatic-builder --subject <Name> --entry <tmp>.json`. Build
|
|
391
|
-
`<tmp>.json` with the attested checks `build` (the story build outcome) and
|
|
391
|
+
`<tmp>.json` with `at` (an ISO timestamp you supply), the attested checks `build` (the story build outcome) and
|
|
392
392
|
`review`, `advancedBecause`, and **the two component-scoped derived results,
|
|
393
393
|
`state-incomplete` and `name-drift`, read off the `verify:check` report Step
|
|
394
394
|
5.5 already ran** — recorded with `method: "derived"`. This is one of two stages
|
|
@@ -428,8 +428,8 @@ groups were created in `tokens.styleGroups`. Append `token-builder` to
|
|
|
428
428
|
|
|
429
429
|
**Record the stage entry:** `node .throughline/scripts/verify-check.mjs
|
|
430
430
|
--record --stage token-builder --entry <tmp>.json` (subject defaults to
|
|
431
|
-
`"system"`; this stage is not per-component). Build `<tmp>.json` with `
|
|
432
|
-
summarizing the collections and modes created, the attested check
|
|
431
|
+
`"system"`; this stage is not per-component). Build `<tmp>.json` with `at` (an ISO
|
|
432
|
+
timestamp you supply), `changed` summarizing the collections and modes created, the attested check
|
|
433
433
|
`contrast-baseline` carrying `result` and a one-line `evidence` naming the
|
|
434
434
|
tightest ratio per mode (`"Light 5.2:1, Dark 4.9:1 — tightest is
|
|
435
435
|
text/onEmphasis on bg/emphasis"`), and `advancedBecause`.
|
|
@@ -363,7 +363,7 @@ Update the manifest: `sync.lastRun` (timestamp), `tokens.lastSync`, confirm
|
|
|
363
363
|
Alongside that, record the stage entry: `node
|
|
364
364
|
.throughline/scripts/verify-check.mjs --record --stage
|
|
365
365
|
token-sync-layer --entry <tmp>.json` (subject defaults to `"system"`, since this
|
|
366
|
-
stage isn't per-component). Build `<tmp>.json` with `changed` summarizing tokens
|
|
366
|
+
stage isn't per-component). Build `<tmp>.json` with `at` (an ISO timestamp you supply), `changed` summarizing tokens
|
|
367
367
|
added, changed, deleted and probable renames, and the attested
|
|
368
368
|
`tokens:validate-output` result as a check, plus `advancedBecause`. The entry
|
|
369
369
|
shape is `.throughline/references/proof-bundle.md` — don't restate it
|
|
@@ -171,7 +171,9 @@ deterministic naming):
|
|
|
171
171
|
visual-validation loop (screenshot → fix any overlaps/misalignment →
|
|
172
172
|
re-screenshot) **and its "Post-build audit (REQUIRED before handoff)"
|
|
173
173
|
read-back checklist** (container type, auto layout, bound variables,
|
|
174
|
-
deterministic names) before the checkpoint.
|
|
174
|
+
deterministic names) before the checkpoint. When you build inline, with no
|
|
175
|
+
`figma-executor`, that includes item 9's `focus-indicator` check: a miss stops
|
|
176
|
+
the build, as it would for the executor.
|
|
175
177
|
|
|
176
178
|
Checkpoint after each component: show all variants, confirm before the next.
|
|
177
179
|
|
|
@@ -357,8 +359,10 @@ any component built with the toggle + manual-swap fallback is listed in
|
|
|
357
359
|
**Record the stage entry, one call per component that actually finished:**
|
|
358
360
|
`node .throughline/scripts/verify-check.mjs --record --stage
|
|
359
361
|
component-builder --subject <Name> --entry <tmp>.json`. Build `<tmp>.json` with
|
|
360
|
-
`changed` (what was built), the attested checks `structural-read-back`,
|
|
361
|
-
`state-baseline` and `figma-name` from what the `figma-executor` returned
|
|
362
|
+
`at` (an ISO timestamp you supply), `changed` (what was built), the attested checks `structural-read-back`,
|
|
363
|
+
`state-baseline` and `figma-name` from what the `figma-executor` returned (or
|
|
364
|
+
what your inline read-back found), `focus-indicator` when that check ran and
|
|
365
|
+
passed (not when it was skipped),
|
|
362
366
|
`review` when a reviewer ran, `screenshot` when one was captured, and
|
|
363
367
|
`advancedBecause`. The entry shape is
|
|
364
368
|
`.throughline/references/proof-bundle.md` — don't restate it here. Write
|
|
@@ -384,7 +384,7 @@ component-pipeline orchestrator; token changes flow through `/sync-figma-tokens`
|
|
|
384
384
|
**Record the stage entry, one call per component finalized this run:**
|
|
385
385
|
`node .throughline/scripts/verify-check.mjs --record --stage
|
|
386
386
|
storybook-chromatic-builder --subject <Name> --entry <tmp>.json`. Build
|
|
387
|
-
`<tmp>.json` with the attested checks `build` (the story build outcome) and
|
|
387
|
+
`<tmp>.json` with `at` (an ISO timestamp you supply), the attested checks `build` (the story build outcome) and
|
|
388
388
|
`review`, `advancedBecause`, and **the two component-scoped derived results,
|
|
389
389
|
`state-incomplete` and `name-drift`, read off the `verify:check` report Step
|
|
390
390
|
5.5 already ran** — recorded with `method: "derived"`. This is one of two stages
|
|
@@ -424,8 +424,8 @@ groups were created in `tokens.styleGroups`. Append `token-builder` to
|
|
|
424
424
|
|
|
425
425
|
**Record the stage entry:** `node .throughline/scripts/verify-check.mjs
|
|
426
426
|
--record --stage token-builder --entry <tmp>.json` (subject defaults to
|
|
427
|
-
`"system"`; this stage is not per-component). Build `<tmp>.json` with `
|
|
428
|
-
summarizing the collections and modes created, the attested check
|
|
427
|
+
`"system"`; this stage is not per-component). Build `<tmp>.json` with `at` (an ISO
|
|
428
|
+
timestamp you supply), `changed` summarizing the collections and modes created, the attested check
|
|
429
429
|
`contrast-baseline` carrying `result` and a one-line `evidence` naming the
|
|
430
430
|
tightest ratio per mode (`"Light 5.2:1, Dark 4.9:1 — tightest is
|
|
431
431
|
text/onEmphasis on bg/emphasis"`), and `advancedBecause`.
|
|
@@ -359,7 +359,7 @@ Update the manifest: `sync.lastRun` (timestamp), `tokens.lastSync`, confirm
|
|
|
359
359
|
Alongside that, record the stage entry: `node
|
|
360
360
|
.throughline/scripts/verify-check.mjs --record --stage
|
|
361
361
|
token-sync-layer --entry <tmp>.json` (subject defaults to `"system"`, since this
|
|
362
|
-
stage isn't per-component). Build `<tmp>.json` with `changed` summarizing tokens
|
|
362
|
+
stage isn't per-component). Build `<tmp>.json` with `at` (an ISO timestamp you supply), `changed` summarizing tokens
|
|
363
363
|
added, changed, deleted and probable renames, and the attested
|
|
364
364
|
`tokens:validate-output` result as a check, plus `advancedBecause`. The entry
|
|
365
365
|
shape is `.throughline/references/proof-bundle.md` — don't restate it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radicool/throughline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Build a complete design system end to end — author in Figma, sync tokens to code, generate Storybook. Usable from Claude Code, Cursor, Codex, or any AGENTS.md agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -571,6 +571,53 @@ For each generated artboard / doc card / icon grid, read the nodes back (via
|
|
|
571
571
|
previously-built components too** — when you touch an existing one, a legacy padded
|
|
572
572
|
wrapper, an inside-aligned stroke, or a transparent variant with no ring at all must
|
|
573
573
|
be retrofitted to the current recipe (see "State handling").
|
|
574
|
+
|
|
575
|
+
**The `focus-indicator` check** is the programmatic half of this item. Run it
|
|
576
|
+
with `figma_execute`. What a miss does depends on who designed the ring:
|
|
577
|
+
- **`figma-executor` returns `BLOCKED`** on a miss, naming the variant. It
|
|
578
|
+
builds from an architect spec, and adding a ring the spec didn't give is a
|
|
579
|
+
design decision it doesn't make.
|
|
580
|
+
- **Building inline**, you designed the ring yourself, so a miss is a build
|
|
581
|
+
error: fix and re-audit like any other item. If the ring is still missing
|
|
582
|
+
when the pass budget below runs out, **stop the build** rather than hand off.
|
|
583
|
+
- **Scope.** It runs only for a component whose name folds (lowercase,
|
|
584
|
+
punctuation stripped) to a Button, an Input / text field, or a Checkbox /
|
|
585
|
+
radio / toggle / chip, the same exact-name match as the state check. A Switch
|
|
586
|
+
or a TextInput isn't in that list, so neither Figma check reads it (the CLI's
|
|
587
|
+
`state-incomplete` does). It also
|
|
588
|
+
needs `project.uiFramework` to be exactly `shadcn`, `tailwind`, `mui`,
|
|
589
|
+
`vanilla-css` or `vanilla`, or absent or `null`. Anything else (`ios-swift`,
|
|
590
|
+
a tier-2 library, any other value) is skipped, and the skip is said.
|
|
591
|
+
- **What it reads.** Every child `COMPONENT` of the set whose
|
|
592
|
+
`variantProperties` has a key folding to `state` with a value folding to
|
|
593
|
+
exactly `focus`. `Focused` or `focus-visible` isn't read. The ring is looked
|
|
594
|
+
for on that variant, or, where the variant wraps the control in other content
|
|
595
|
+
(an Input with a label or helper text), on the child frame that is the
|
|
596
|
+
control, the node the recipe above puts the ring on. Reading **zero** focus
|
|
597
|
+
variants on an in-scope component is not a pass: it means the `state` axis
|
|
598
|
+
has no `focus` value, which the state check stops on.
|
|
599
|
+
- **A focus variable** is any variable whose name has a `focus` segment:
|
|
600
|
+
`border/focus`, `color/border/focus`, `color/focus/ring` and
|
|
601
|
+
`color/focus/ringOnFill` all count. Systems name their ring role differently,
|
|
602
|
+
and a collection prefix is common. A raw colour, or a variable with no `focus`
|
|
603
|
+
segment, doesn't count.
|
|
604
|
+
- **Ring recipes** (`shadcn`, `tailwind`, absent or `null`; `mui` on anything
|
|
605
|
+
but an input). The control has either a `DROP_SHADOW` effect with
|
|
606
|
+
`visible !== false`, `spread > 0` and `boundVariables.color` on a focus
|
|
607
|
+
variable, or a direct child with `layoutPositioning = "ABSOLUTE"` and
|
|
608
|
+
`strokeAlign = "OUTSIDE"` whose stroke is bound to a focus variable.
|
|
609
|
+
`spread > 0` matters: binding an effect's colour can silently zero its spread
|
|
610
|
+
(see `figma-scripting.md`), which leaves a ring that is bound and invisible.
|
|
611
|
+
Only the ring is asserted. The recipe's border recolour is styling, and a
|
|
612
|
+
control whose border keeps its normal colour beside a correct ring passes.
|
|
613
|
+
- **`mui` inputs.** The control's own stroke is bound to a focus variable, and its
|
|
614
|
+
weight to a variable (the recipe's `width/focus`).
|
|
615
|
+
- **`vanilla-css` / `vanilla`.** The control or a direct child carries a stroke
|
|
616
|
+
bound to a focus variable with `strokeAlign = "OUTSIDE"`. The `offset/focus` gap
|
|
617
|
+
isn't asserted.
|
|
618
|
+
- **The result.** On a miss, stop and name the variant and what it lacks. On a
|
|
619
|
+
pass, `component-builder` records `focus-indicator` as an attested check. A
|
|
620
|
+
skip records nothing.
|
|
574
621
|
10. **Visual** — the screenshot (from the validation loop) shows no overlaps,
|
|
575
622
|
misalignment, lopsided hug/fill sizing, or clipped strokes/focus rings.
|
|
576
623
|
11. **Usage band rendered by the builder** — the card's `Usage` frame was
|
|
@@ -206,6 +206,7 @@ keyed by component?"
|
|
|
206
206
|
| `structural-read-back` | attested | `component-builder` |
|
|
207
207
|
| `state-baseline` | attested | `component-builder` |
|
|
208
208
|
| `figma-name` | attested | `component-builder` |
|
|
209
|
+
| `focus-indicator` | attested | `component-builder` |
|
|
209
210
|
| `review` | attested | the dispatching skill |
|
|
210
211
|
| `build` | attested | `storybook-chromatic-builder` |
|
|
211
212
|
| `orphan-token` | derived | not recorded: re-derived on every `verify:check` run |
|
|
@@ -277,6 +278,12 @@ One stage file with two subjects — `Button`, freshly built and reviewed, and
|
|
|
277
278
|
"result": "pass",
|
|
278
279
|
"evidence": "Figma node named Button"
|
|
279
280
|
},
|
|
281
|
+
{
|
|
282
|
+
"name": "focus-indicator",
|
|
283
|
+
"method": "attested",
|
|
284
|
+
"result": "pass",
|
|
285
|
+
"evidence": "shadcn: every focus variant carries a visible ring bound to a focus variable"
|
|
286
|
+
},
|
|
280
287
|
{
|
|
281
288
|
"name": "review",
|
|
282
289
|
"method": "attested",
|
package/scripts/README.md
CHANGED
|
@@ -11,7 +11,7 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
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
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` |
|
|
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. `unnamed-control` fails a Button or IconButton (or a component whose doc record says `archetype: "button"`) with no accessible name: no text, and no `aria-label`, `aria-labelledby` or `title` on it or anything inside it. `colour-only-status` fails a Badge or Tag (or a record with `archetype: "badge"`) the same way, so its status isn't carried by colour alone. Both read script files only (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`), abstain rather than guess on anything they can't read, print how many elements they checked and abstained on, and can be switched off with `--skip`. Fails when an enabled rule had nothing to check, so a green run always means something was verified. | `adherence:check` |
|
|
15
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` |
|
|
16
16
|
| `lib/crosswalk.mjs` | Shared loader + structural validation for `crosswalk.json` (used by the validator and reverse-index). | copied alongside |
|
|
17
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 |
|
|
@@ -26,7 +26,7 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
26
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
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
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` |
|
|
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. `validate-adherence.mjs` also uses it to find Badges. | copied alongside `verify-check.mjs` and `validate-adherence.mjs` |
|
|
30
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` |
|
|
31
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) |
|
|
32
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) |
|
|
@@ -192,11 +192,13 @@ conflict, or remaining reference), `2` bad CLI arguments.
|
|
|
192
192
|
|
|
193
193
|
## How the skill installs these
|
|
194
194
|
|
|
195
|
-
`
|
|
196
|
-
`
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
195
|
+
The file lists live in the skills, as `src` → `dest` copy lines that
|
|
196
|
+
`ci/validate-install-sets.mjs` checks. They are not repeated here, so they can't
|
|
197
|
+
drift.
|
|
198
|
+
|
|
199
|
+
`token-crosswalk-builder` copies the files in its Step 2 list
|
|
200
|
+
(`skills/token-crosswalk-builder/SKILL.md`) into the user's `packages/tokens/`,
|
|
201
|
+
then wires `packages/tokens/package.json`:
|
|
200
202
|
|
|
201
203
|
```jsonc
|
|
202
204
|
"scripts": {
|
|
@@ -205,11 +207,11 @@ wires `packages/tokens/package.json`:
|
|
|
205
207
|
}
|
|
206
208
|
```
|
|
207
209
|
|
|
208
|
-
`token-sync-layer` copies
|
|
209
|
-
`
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
`token-sync-layer` copies its token-toolkit list
|
|
211
|
+
(`skills/token-sync-layer/SKILL.md`, Step 4) and wires `"tokens:validate-output"`.
|
|
212
|
+
The files travel together: `sd-native.mjs` and the validator both import
|
|
213
|
+
`lib/dtcg.mjs` and `lib/native-literal.mjs`, so installing any one of them alone
|
|
214
|
+
breaks at import time.
|
|
213
215
|
|
|
214
216
|
The scripts version with the user's repo so their CI runs them locally — a path
|
|
215
217
|
inside the plugin install would not be reachable from the user's CI.
|
|
@@ -17,6 +17,7 @@ export function buildIndex(records) {
|
|
|
17
17
|
generatedFrom: 'design-system/docs/components/*.doc.json',
|
|
18
18
|
components: records.map((r) => ({
|
|
19
19
|
name: r.name,
|
|
20
|
+
archetype: r.archetype ?? null,
|
|
20
21
|
summary: r.summary ?? '',
|
|
21
22
|
description: r.description ?? '',
|
|
22
23
|
whenToUse: r.whenToUse ?? [],
|
|
@@ -16,6 +16,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
16
16
|
import { join, dirname, relative, sep } from 'node:path';
|
|
17
17
|
import { walk, normalizeName } from './lib/source-scan.mjs';
|
|
18
18
|
import { flattenDtcg, flattenDtcgTypes, resolveValue } from './lib/dtcg.mjs';
|
|
19
|
+
import { resolveArchetype } from './lib/component-states.mjs';
|
|
19
20
|
|
|
20
21
|
// Named imports from one package, alias included: `{ Card as Panel }`.
|
|
21
22
|
const IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
@@ -217,6 +218,180 @@ export function templateView(text) {
|
|
|
217
218
|
return out.join('');
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
// The JSX subtree reader (#140): docs/specs/2026-09-29-component-accessibility-checks.md.
|
|
222
|
+
// It runs alongside ELEMENT, not in place of it: only the usage rules read it.
|
|
223
|
+
// Every function returns null on anything it can't pair cleanly, and the caller
|
|
224
|
+
// turns that into `paired: false`, never a guess.
|
|
225
|
+
const IDENT_START = /[A-Za-z_$]/;
|
|
226
|
+
const NAME_CHAR = /[\w$.:-]/;
|
|
227
|
+
|
|
228
|
+
// i is at an opening quote; returns the index just past its close.
|
|
229
|
+
function skipString(code, i) {
|
|
230
|
+
const q = code[i];
|
|
231
|
+
for (let j = i + 1; j < code.length; j++) {
|
|
232
|
+
if (code[j] === '\\') j++;
|
|
233
|
+
else if (q === '`' && code[j] === '$' && code[j + 1] === '{') {
|
|
234
|
+
const end = skipBraces(code, j + 1);
|
|
235
|
+
if (end === null) return null;
|
|
236
|
+
j = end - 1;
|
|
237
|
+
} else if (code[j] === q) return j + 1;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// i is at `{`; returns the index just past its matching `}`. JSX inside an
|
|
243
|
+
// expression (`{ok && <Badge>Done</Badge>}`) is read as JSX, so an apostrophe
|
|
244
|
+
// in its text isn't taken for a string. A `<` after an identifier character is
|
|
245
|
+
// a comparison or a type argument.
|
|
246
|
+
function skipBraces(code, i) {
|
|
247
|
+
let depth = 0;
|
|
248
|
+
for (let j = i; j < code.length; j++) {
|
|
249
|
+
const c = code[j];
|
|
250
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
251
|
+
const end = skipString(code, j);
|
|
252
|
+
if (end === null) return null;
|
|
253
|
+
j = end - 1;
|
|
254
|
+
} else if (c === '<' && !/[\w$.)\]]/.test(code[j - 1] ?? '') && /[A-Za-z>]/.test(code[j + 1] ?? '')) {
|
|
255
|
+
const el = parseElement(code, j);
|
|
256
|
+
if (el === null) return null;
|
|
257
|
+
j = el.end - 1;
|
|
258
|
+
} else if (c === '{') depth++;
|
|
259
|
+
else if (c === '}' && --depth === 0) return j + 1;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// i is at `<`. Reads the name and attributes up to `>` or `/>`.
|
|
265
|
+
function parseTag(code, i) {
|
|
266
|
+
let j = i + 1;
|
|
267
|
+
let name = '';
|
|
268
|
+
while (j < code.length && NAME_CHAR.test(code[j])) name += code[j++];
|
|
269
|
+
const attrs = [];
|
|
270
|
+
let spread = false;
|
|
271
|
+
for (;;) {
|
|
272
|
+
while (/\s/.test(code[j] ?? '')) j++;
|
|
273
|
+
if (j >= code.length) return null;
|
|
274
|
+
if (code.startsWith('/>', j)) return { name, attrs, spread, selfClosing: true, end: j + 2 };
|
|
275
|
+
if (code[j] === '>') return { name, attrs, spread, selfClosing: false, end: j + 1 };
|
|
276
|
+
if (code[j] === '{') {
|
|
277
|
+
const end = skipBraces(code, j);
|
|
278
|
+
if (end === null || !code.slice(j + 1, end - 1).trim().startsWith('...')) return null;
|
|
279
|
+
spread = true;
|
|
280
|
+
j = end;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!IDENT_START.test(code[j])) return null;
|
|
284
|
+
let attr = '';
|
|
285
|
+
while (j < code.length && NAME_CHAR.test(code[j])) attr += code[j++];
|
|
286
|
+
while (/\s/.test(code[j] ?? '')) j++;
|
|
287
|
+
if (code[j] !== '=') {
|
|
288
|
+
attrs.push({ name: attr, kind: 'bare', value: null });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
j++;
|
|
292
|
+
while (/\s/.test(code[j] ?? '')) j++;
|
|
293
|
+
if (code[j] === '"' || code[j] === "'") {
|
|
294
|
+
const end = skipString(code, j);
|
|
295
|
+
if (end === null) return null;
|
|
296
|
+
attrs.push({ name: attr, kind: 'literal', value: code.slice(j + 1, end - 1) });
|
|
297
|
+
j = end;
|
|
298
|
+
} else if (code[j] === '{') {
|
|
299
|
+
const end = skipBraces(code, j);
|
|
300
|
+
if (end === null) return null;
|
|
301
|
+
attrs.push({ name: attr, kind: 'expr', value: null });
|
|
302
|
+
j = end;
|
|
303
|
+
} else return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// i is just past an opening tag named `name`; reads to its matching close.
|
|
308
|
+
function parseChildren(code, i, name) {
|
|
309
|
+
const children = [];
|
|
310
|
+
let j = i;
|
|
311
|
+
while (j < code.length) {
|
|
312
|
+
if (code[j] === '<') {
|
|
313
|
+
if (code[j + 1] === '/') {
|
|
314
|
+
const close = code.indexOf('>', j);
|
|
315
|
+
if (close === -1 || code.slice(j + 2, close).trim() !== name) return null;
|
|
316
|
+
return { children, end: close + 1 };
|
|
317
|
+
}
|
|
318
|
+
const el = parseElement(code, j);
|
|
319
|
+
if (el === null) return null;
|
|
320
|
+
children.push({ type: 'element', name: el.name, attrs: el.attrs, spread: el.spread, selfClosing: el.selfClosing, children: el.children });
|
|
321
|
+
j = el.end;
|
|
322
|
+
} else if (code[j] === '{') {
|
|
323
|
+
const end = skipBraces(code, j);
|
|
324
|
+
if (end === null) return null;
|
|
325
|
+
// A JSX comment `{/* */}` is blank by now, and names nothing.
|
|
326
|
+
if (code.slice(j + 1, end - 1).trim()) children.push({ type: 'expr', value: code.slice(j + 1, end - 1) });
|
|
327
|
+
j = end;
|
|
328
|
+
} else {
|
|
329
|
+
let k = j;
|
|
330
|
+
while (k < code.length && code[k] !== '<' && code[k] !== '{') k++;
|
|
331
|
+
children.push({ type: 'text', value: code.slice(j, k) });
|
|
332
|
+
j = k;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function parseElement(code, i) {
|
|
339
|
+
const tag = parseTag(code, i);
|
|
340
|
+
if (tag === null) return null;
|
|
341
|
+
if (tag.selfClosing) return { ...tag, children: [] };
|
|
342
|
+
const body = parseChildren(code, tag.end, tag.name);
|
|
343
|
+
if (body === null) return null;
|
|
344
|
+
return { ...tag, children: body.children, end: body.end };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// One entry per opening tag ELEMENT matches on an imported name, nested ones
|
|
348
|
+
// included: a Button inside a Card is its own entry and also one of the Card's
|
|
349
|
+
// children. `code` is comment-blanked.
|
|
350
|
+
export function readSubtrees(code, imported) {
|
|
351
|
+
const subtrees = [];
|
|
352
|
+
for (const el of code.matchAll(ELEMENT)) {
|
|
353
|
+
const declared = imported.get(el[1]);
|
|
354
|
+
if (!declared || el[2]) continue;
|
|
355
|
+
const line = lineOf(code, el.index);
|
|
356
|
+
let parsed;
|
|
357
|
+
try {
|
|
358
|
+
parsed = parseElement(code, el.index);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
// Nesting past the stack's depth abstains like anything else unpaired.
|
|
361
|
+
if (!(e instanceof RangeError)) throw e;
|
|
362
|
+
parsed = null;
|
|
363
|
+
}
|
|
364
|
+
if (parsed === null) {
|
|
365
|
+
let tag = null;
|
|
366
|
+
try {
|
|
367
|
+
tag = parseTag(code, el.index);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
if (!(e instanceof RangeError)) throw e;
|
|
370
|
+
}
|
|
371
|
+
subtrees.push({
|
|
372
|
+
component: declared,
|
|
373
|
+
line,
|
|
374
|
+
selfClosing: tag?.selfClosing ?? false,
|
|
375
|
+
paired: false,
|
|
376
|
+
attrs: tag?.attrs ?? [],
|
|
377
|
+
spread: tag?.spread ?? false,
|
|
378
|
+
children: [],
|
|
379
|
+
});
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
subtrees.push({
|
|
383
|
+
component: declared,
|
|
384
|
+
line,
|
|
385
|
+
selfClosing: parsed.selfClosing,
|
|
386
|
+
paired: true,
|
|
387
|
+
attrs: parsed.attrs,
|
|
388
|
+
spread: parsed.spread,
|
|
389
|
+
children: parsed.children,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return subtrees;
|
|
393
|
+
}
|
|
394
|
+
|
|
220
395
|
export function extract(text, pkg, path = '') {
|
|
221
396
|
if (path.endsWith('.html')) text = templateView(text);
|
|
222
397
|
const code = blankComments(text, path);
|
|
@@ -258,7 +433,9 @@ export function extract(text, pkg, path = '') {
|
|
|
258
433
|
if (value) literals.push({ value, line: lineOf(colourText, m.index) });
|
|
259
434
|
}
|
|
260
435
|
|
|
261
|
-
|
|
436
|
+
const subtrees = /\.(jsx?|tsx?|mjs|cjs)$/.test(path) ? readSubtrees(code, imported) : [];
|
|
437
|
+
|
|
438
|
+
return { imported, elements, usages, literals, dimensions: extractDimensions(text, path), subtrees };
|
|
262
439
|
}
|
|
263
440
|
|
|
264
441
|
// value -> the token paths that hold it. Repeatable --tokens, because a real
|
|
@@ -520,6 +697,72 @@ export function partOwner(name, built) {
|
|
|
520
697
|
return best;
|
|
521
698
|
}
|
|
522
699
|
|
|
700
|
+
// The usage rules (#140). Both judge an element the same way and differ only in
|
|
701
|
+
// which components they target. The order matters: abstain before naming, so a
|
|
702
|
+
// spread that may carry the label is never read as unnamed.
|
|
703
|
+
export const A11Y_RULES = ['unnamed-control', 'colour-only-status'];
|
|
704
|
+
const NAMING = ['aria-label', 'aria-labelledby', 'title'];
|
|
705
|
+
// On a descendant, a prop that may render text: an icon's `size` or `color`
|
|
706
|
+
// can't, an i18n component's `i18nKey` or `id` can.
|
|
707
|
+
const LABEL_LIKE = new Set(['label', 'alt', 'text', 'i18nKey', 'defaultMessage', 'message', 'id']);
|
|
708
|
+
const isLabelLike = (name) => LABEL_LIKE.has(name) || /(Label|Text)$/.test(name);
|
|
709
|
+
// On an element with nothing inside, props that can never be its label. An icon
|
|
710
|
+
// prop takes a component, not text.
|
|
711
|
+
const NEVER_LABEL = new Set([
|
|
712
|
+
'className',
|
|
713
|
+
'style',
|
|
714
|
+
'disabled',
|
|
715
|
+
'type',
|
|
716
|
+
'key',
|
|
717
|
+
'ref',
|
|
718
|
+
'id',
|
|
719
|
+
'aria-hidden',
|
|
720
|
+
'asChild',
|
|
721
|
+
'icon',
|
|
722
|
+
'leadingIcon',
|
|
723
|
+
'trailingIcon',
|
|
724
|
+
...NAMING,
|
|
725
|
+
]);
|
|
726
|
+
|
|
727
|
+
function namesItself(attrs) {
|
|
728
|
+
return attrs.some((a) => NAMING.includes(a.name) && (a.kind === 'expr' || (a.kind === 'literal' && a.value.trim() !== '')));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function* descendants(children) {
|
|
732
|
+
for (const c of children) {
|
|
733
|
+
if (c.type !== 'element') continue;
|
|
734
|
+
yield c;
|
|
735
|
+
yield* descendants(c.children);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// JSX drops whitespace-only text, and an expression may be the label.
|
|
740
|
+
function hasText(children) {
|
|
741
|
+
return children.some(
|
|
742
|
+
(c) => c.type === 'expr' || (c.type === 'text' && c.value.trim() !== '') || (c.type === 'element' && hasText(c.children)),
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// 'named', 'unnamed', or 'abstain' when the name could arrive some way the
|
|
747
|
+
// reader can't see.
|
|
748
|
+
export function judgeName(el, record) {
|
|
749
|
+
if (!el.paired || el.spread) return 'abstain';
|
|
750
|
+
const inside = [...descendants(el.children)];
|
|
751
|
+
if (inside.some((d) => d.spread || d.attrs.some((a) => isLabelLike(a.name)))) return 'abstain';
|
|
752
|
+
if (namesItself(el.attrs) || inside.some((d) => namesItself(d.attrs))) return 'named';
|
|
753
|
+
if (hasText(el.children)) return 'named';
|
|
754
|
+
// `<Button label="Save"></Button>` has no children to judge by either, so it
|
|
755
|
+
// gets the same scrutiny as `<Button label="Save" />`. JSX drops whitespace-only
|
|
756
|
+
// text, so that counts as nothing inside too.
|
|
757
|
+
if (el.children.every((c) => c.type === 'text' && c.value.trim() === '')) {
|
|
758
|
+
// With no record there's no telling a variant from a label prop.
|
|
759
|
+
const modelled = new Set([...Object.keys(record?.variants ?? {}), ...Object.keys(record?.states ?? {})]);
|
|
760
|
+
const unknown = (a) => !modelled.has(a.name) && !NEVER_LABEL.has(a.name) && !/^on[A-Z]/.test(a.name) && !/^data-/.test(a.name);
|
|
761
|
+
if (el.attrs.some(unknown)) return 'abstain';
|
|
762
|
+
}
|
|
763
|
+
return 'unnamed';
|
|
764
|
+
}
|
|
765
|
+
|
|
523
766
|
export function validate({
|
|
524
767
|
built = [],
|
|
525
768
|
index = { components: [] },
|
|
@@ -557,9 +800,31 @@ export function validate({
|
|
|
557
800
|
dynamic: 0,
|
|
558
801
|
knownComponents: new Set(),
|
|
559
802
|
undocumented: new Set(),
|
|
803
|
+
// No inert failure for these: an app with no Badge isn't misconfigured. A
|
|
804
|
+
// rule that checked nothing shows as 0 checked instead.
|
|
805
|
+
a11y: Object.fromEntries(A11Y_RULES.filter((rule) => !off.has(rule)).map((rule) => [rule, { checked: 0, abstained: 0 }])),
|
|
560
806
|
};
|
|
561
807
|
|
|
562
|
-
for (const { path, elements = [], usages, literals, dimensions = [] } of files) {
|
|
808
|
+
for (const { path, elements = [], usages, literals, dimensions = [], subtrees = [] } of files) {
|
|
809
|
+
for (const el of subtrees) {
|
|
810
|
+
const key = normalizeName(el.component);
|
|
811
|
+
const record = records.get(key);
|
|
812
|
+
const targeted = {
|
|
813
|
+
'unnamed-control': key === 'button' || key === 'iconbutton' || record?.archetype === 'button',
|
|
814
|
+
'colour-only-status': resolveArchetype(record ?? { name: el.component }) === 'badge',
|
|
815
|
+
};
|
|
816
|
+
for (const rule of A11Y_RULES) {
|
|
817
|
+
if (!targeted[rule] || !stats.a11y[rule]) continue;
|
|
818
|
+
const verdict = judgeName(el, record);
|
|
819
|
+
if (verdict === 'abstain') {
|
|
820
|
+
stats.a11y[rule].abstained += 1;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
stats.a11y[rule].checked += 1;
|
|
824
|
+
if (verdict === 'unnamed') failures.push({ rule, component: el.component, file: path, line: el.line });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
563
828
|
for (const e of elements) {
|
|
564
829
|
stats.elements += 1;
|
|
565
830
|
if (off.has('unknown-component') || builtKeys.has(normalizeName(e.component))) continue;
|
|
@@ -719,6 +984,10 @@ export function formatReport(r) {
|
|
|
719
984
|
` colour: ${s.colourTokens} token values comparable, ${s.colourSkipped.unresolvable} skipped as unresolvable, ${s.colourSkipped.nonHex} skipped as non-hex`,
|
|
720
985
|
` dimensions: ${dimensionTotal} token values comparable — ${DIMENSION_CATEGORIES.map((c) => `${c} ${s.dimensionTokens[c]}`).join(', ')}`,
|
|
721
986
|
);
|
|
987
|
+
const a11y = Object.entries(s.a11y);
|
|
988
|
+
if (a11y.length) {
|
|
989
|
+
lines.push(` a11y: ${a11y.map(([rule, c]) => `${rule} ${c.checked} checked, ${c.abstained} abstained`).join('; ')}`);
|
|
990
|
+
}
|
|
722
991
|
for (const e of r.excluded) {
|
|
723
992
|
lines.push(` excluded: ${e.files} file(s) in ${e.dir}, the package that owns --tokens`);
|
|
724
993
|
}
|
|
@@ -743,6 +1012,14 @@ export function formatReport(r) {
|
|
|
743
1012
|
lines.push(
|
|
744
1013
|
` - [${f.rule}] ${f.category} ${f.written}${f.written === f.value ? '' : ` (${f.value})`} at ${f.file}:${f.line} — ${f.tokens.join(', ')} resolve${f.tokens.length === 1 ? 's' : ''} to exactly this value`,
|
|
745
1014
|
);
|
|
1015
|
+
} else if (f.rule === 'unnamed-control') {
|
|
1016
|
+
lines.push(
|
|
1017
|
+
` - [${f.rule}] <${f.component}> at ${f.file}:${f.line} — no accessible name: no text, and no aria-label, aria-labelledby or title on it or anything inside it`,
|
|
1018
|
+
);
|
|
1019
|
+
} else if (f.rule === 'colour-only-status') {
|
|
1020
|
+
lines.push(
|
|
1021
|
+
` - [${f.rule}] <${f.component}> at ${f.file}:${f.line} — status in colour alone: no text, and no named icon inside it`,
|
|
1022
|
+
);
|
|
746
1023
|
} else if (f.rule === 'nothing-scanned') {
|
|
747
1024
|
lines.push(
|
|
748
1025
|
` - [${f.rule}] ${f.files} file(s) yielded no component reference, colour literal or dimension literal, so this run verified nothing. Check --root points at the consuming app, and that --package is the specifier that app actually imports from.`,
|
|
@@ -793,7 +1070,13 @@ export function formatReport(r) {
|
|
|
793
1070
|
return lines;
|
|
794
1071
|
}
|
|
795
1072
|
|
|
796
|
-
export const SKIPPABLE = [
|
|
1073
|
+
export const SKIPPABLE = [
|
|
1074
|
+
'unknown-component',
|
|
1075
|
+
'unknown-variant-value',
|
|
1076
|
+
'token-exists-for-literal',
|
|
1077
|
+
'token-exists-for-dimension',
|
|
1078
|
+
...A11Y_RULES,
|
|
1079
|
+
];
|
|
797
1080
|
|
|
798
1081
|
function main() {
|
|
799
1082
|
let values;
|
|
@@ -871,9 +1154,9 @@ function main() {
|
|
|
871
1154
|
.map(([dir, n]) => ({ dir: join(values.root, relative(realRoot, dir)), files: n }));
|
|
872
1155
|
|
|
873
1156
|
for (const path of scanned) {
|
|
874
|
-
const { elements, usages, literals, dimensions } = extract(readFileSync(path, 'utf8'), values.package, path);
|
|
875
|
-
if (elements.length || usages.length || literals.length || dimensions.length) {
|
|
876
|
-
files.push({ path, elements, usages, literals, dimensions });
|
|
1157
|
+
const { elements, usages, literals, dimensions, subtrees } = extract(readFileSync(path, 'utf8'), values.package, path);
|
|
1158
|
+
if (elements.length || usages.length || literals.length || dimensions.length || subtrees.length) {
|
|
1159
|
+
files.push({ path, elements, usages, literals, dimensions, subtrees });
|
|
877
1160
|
}
|
|
878
1161
|
}
|
|
879
1162
|
|
package/scripts/verify-check.mjs
CHANGED
|
@@ -313,6 +313,12 @@ export function checkProof({ manifest, root, derived = [], built = [], meta = {}
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
for (const check of recordedChecks(stageFile)) {
|
|
316
|
+
// An attested check is an agent's live observation. It is shown so a
|
|
317
|
+
// human can see what was claimed, and it never decides the run.
|
|
318
|
+
if (check.method === 'attested') {
|
|
319
|
+
informational.push({ rule: 'attested', stage, ...check });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
316
322
|
if (check.method !== 'derived') continue;
|
|
317
323
|
const scope = DERIVED_RULE_SCOPE[check.name];
|
|
318
324
|
// A rule this run did not compute cannot contradict anything, and
|
|
@@ -372,6 +378,8 @@ function informationalDetail(i) {
|
|
|
372
378
|
switch (i.rule) {
|
|
373
379
|
case 'archetype-unknown':
|
|
374
380
|
return `${i.name} — neither an "archetype" field nor its name resolves to a known archetype, so no baseline states are asserted for it.`;
|
|
381
|
+
case 'attested':
|
|
382
|
+
return `${i.stage} · ${i.subject ?? '(stage)'} — ${i.name} ${i.result}${i.evidence ? `: ${i.evidence}` : ''}. Observed by an agent, not re-derived here.`;
|
|
375
383
|
case 'proof-unadopted':
|
|
376
384
|
return 'design-system.json has no "verification" key, so this system has not adopted the proof bundle and the proof-integrity checks are skipped. The first verify-check.mjs --record adopts it.';
|
|
377
385
|
default:
|