@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 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. Component-level accessibility is the half still ahead: focus indicators, status conveyed by more than color, text alternatives.
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 `changed`
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 `changed`
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 `changed`
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.20.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
- `token-crosswalk-builder` copies `lib/crosswalk.mjs`, `lib/dtcg.mjs`,
196
- `lib/source-scan.mjs`, `validate-crosswalk.mjs`, `build-reverse-index.mjs`,
197
- `guard-token-removal.mjs`, and
198
- `crosswalk.schema.json` into the user's `packages/tokens/scripts/` (schema beside `crosswalk.json`), then
199
- wires `packages/tokens/package.json`:
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 `validate-token-output.mjs`, `lib/dtcg.mjs`,
209
- `lib/native-literal.mjs`, **and** `lib/sd-native.mjs`, and wires
210
- `"tokens:validate-output"`. All four travel together: `sd-native.mjs` and the
211
- validator both import `lib/dtcg.mjs` and `lib/native-literal.mjs`, so
212
- installing any one of them alone breaks at import time.
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
- return { imported, elements, usages, literals, dimensions: extractDimensions(text, path) };
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 = ['unknown-component', 'unknown-variant-value', 'token-exists-for-literal', 'token-exists-for-dimension'];
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
 
@@ -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: