@radicool/throughline 0.19.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.
Files changed (45) hide show
  1. package/README.md +2 -2
  2. package/adapters/codex/prompts/component-builder.md +29 -2
  3. package/adapters/codex/prompts/document-component.md +10 -1
  4. package/adapters/codex/prompts/icon-system-builder.md +4 -0
  5. package/adapters/codex/prompts/storybook-chromatic-builder.md +114 -7
  6. package/adapters/codex/prompts/token-builder.md +110 -3
  7. package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
  8. package/adapters/codex/prompts/token-sheet-builder.md +4 -0
  9. package/adapters/codex/prompts/token-sync-layer.md +140 -20
  10. package/adapters/cursor/.cursor/commands/document-component.md +10 -1
  11. package/adapters/cursor/.cursor/rules/component-builder.mdc +29 -2
  12. package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +4 -0
  13. package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +114 -7
  14. package/adapters/cursor/.cursor/rules/token-builder.mdc +110 -3
  15. package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +2 -0
  16. package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +4 -0
  17. package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +140 -20
  18. package/adapters/generic/commands/document-component.md +10 -1
  19. package/adapters/generic/skills/component-builder/SKILL.md +29 -2
  20. package/adapters/generic/skills/icon-system-builder/SKILL.md +4 -0
  21. package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +114 -7
  22. package/adapters/generic/skills/token-builder/SKILL.md +110 -3
  23. package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
  24. package/adapters/generic/skills/token-sheet-builder/SKILL.md +4 -0
  25. package/adapters/generic/skills/token-sync-layer/SKILL.md +140 -20
  26. package/package.json +1 -1
  27. package/references/component-doc-schema.md +11 -1
  28. package/references/figma-component-standards.md +61 -2
  29. package/references/figma-scripting.md +12 -2
  30. package/references/manifest-schema.md +42 -2
  31. package/references/proof-bundle.md +312 -0
  32. package/references/sync-adapters.md +38 -53
  33. package/scripts/README.md +150 -14
  34. package/scripts/build-doc-card-builder.mjs +1 -1
  35. package/scripts/build-docs-digest.mjs +1 -0
  36. package/scripts/grep-color-usage.mjs +10 -30
  37. package/scripts/guard-token-removal.mjs +10 -26
  38. package/scripts/lib/component-states.mjs +56 -0
  39. package/scripts/lib/contrast.mjs +117 -0
  40. package/scripts/lib/dtcg.mjs +3 -2
  41. package/scripts/lib/proof.mjs +186 -0
  42. package/scripts/lib/source-scan.mjs +45 -0
  43. package/scripts/validate-adherence.mjs +1178 -0
  44. package/scripts/validate-token-output.mjs +468 -63
  45. package/scripts/verify-check.mjs +718 -0
@@ -372,8 +372,20 @@ re-describing it:
372
372
  `${CLAUDE_PLUGIN_ROOT}/references/figma-scripting.md` — `getNodeByIdAsync`, and
373
373
  an explicit `timeout` since multi-card font-loading writes blow past the default
374
374
  ~5s budget), then inside it:
375
- - set the `Status Label` text to the new status (e.g. `stable`);
376
- - re-bind the `Status` chip fill to the matching semantic color variable
375
+ - locate the status chip under either header shape, the way the doc-card
376
+ builder's header guard does: find the header band structurally (the card's
377
+ child frame that is neither `Usage` nor the specimen nor an ancestor of it),
378
+ then search its first child, the title row, for a descendant named `Status`
379
+ (to-spec cards) or `Status Pill` (legacy cards). The chip's label is its
380
+ `Status Label` text node; a legacy pill has no named label, so use its one
381
+ TEXT descendant. If no chip is found, or the label doesn't resolve to exactly
382
+ one text node, **skip that card** without writing anything to it and record
383
+ its name — catch per card, so one miss never aborts the rest of a multi-card
384
+ write. After the loop, tell the user by name every card that still shows the
385
+ old status in Figma — the manifest already says `stable`, so a silent skip
386
+ leaves the card lying;
387
+ - set the label text to the new status (e.g. `stable`);
388
+ - re-bind the chip fill to the matching semantic color variable
377
389
  (`stable` → success, `draft`/`beta` → warning, `deprecated` → neutral/danger)
378
390
  — re-bind the variable, don't hardcode a hex, so it stays mode-aware.
379
391
  Then re-run the canonical doc-card builder
@@ -559,6 +571,53 @@ For each generated artboard / doc card / icon grid, read the nodes back (via
559
571
  previously-built components too** — when you touch an existing one, a legacy padded
560
572
  wrapper, an inside-aligned stroke, or a transparent variant with no ring at all must
561
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.
562
621
  10. **Visual** — the screenshot (from the validation loop) shows no overlaps,
563
622
  misalignment, lopsided hug/fill sizing, or clipped strokes/focus rings.
564
623
  11. **Usage band rendered by the builder** — the card's `Usage` frame was
@@ -51,8 +51,9 @@ wrong document.
51
51
 
52
52
  **So the preflight must assert the active file's *identity*, not just connection:**
53
53
 
54
- 1. **Know your target `fileKey`** before writing (the one your architect read, or
55
- the one the workflow is scoped to).
54
+ 1. **Know your target `fileKey`** before writing. It is `figma.fileKey` in
55
+ `design-system.json` at the working-directory root. If that field is absent,
56
+ stop and ask which file to write to — do not assume the active one is right.
56
57
  2. **Read the active file and compare.** `figma_get_status` reports
57
58
  `currentFileKey` / `currentFileName`; `figma_list_open_files` shows every
58
59
  connected file and which is active. Confirm the active `fileKey` equals your
@@ -61,6 +62,15 @@ wrong document.
61
62
  3. **If it drifted, navigate — don't just retry.** `figma_navigate` to the target
62
63
  file URL, then re-read status to confirm the switch took, then write. A bare
63
64
  re-run without navigating will hit the wrong file again.
65
+ 4. **If it still doesn't match, stop.** Name both files. The usual cause is that
66
+ the target file does not have the Desktop Bridge plugin open.
67
+
68
+ Run this before the first write, and again right before any replace — the
69
+ build-verify-then-replace finalize deletes the existing node, so it is the write
70
+ that destroys work in the wrong file. The `figma-executor` agent runs it as step 1
71
+ of its contract. **When a skill builds inline instead** (a host with no subagent
72
+ dispatch, or a write-back the skill does itself), nothing runs it for you: the
73
+ skill has to.
64
74
 
65
75
  This is the file-level analogue of the live-instance discipline above: the
66
76
  instance preflight guards against *two* live bridges; this guards against the *one*
@@ -11,11 +11,11 @@ what changed. Gating decisions are made by reading this file: if a prerequisite
11
11
  field is unset, the skill **offers** to run the prerequisite skill rather than
12
12
  bailing or running silently.
13
13
 
14
- ## Schema (schemaVersion 6)
14
+ ## Schema (schemaVersion 7)
15
15
 
16
16
  ```json
17
17
  {
18
- "schemaVersion": 6,
18
+ "schemaVersion": 7,
19
19
  "user": {
20
20
  "codingLevel": "new"
21
21
  },
@@ -102,6 +102,7 @@ bailing or running silently.
102
102
  "completedAt": null,
103
103
  "journalScaffolded": false
104
104
  },
105
+ "verification": null,
105
106
  "completedSkills": []
106
107
  }
107
108
  ```
@@ -346,6 +347,41 @@ retrofit stands so a later session can resume.
346
347
  - `journalScaffolded` — whether the `docs/design-system/` decision journal has been
347
348
  created for this retrofit (offered default-on by `retrofit-planner`).
348
349
 
350
+ ### `verification`
351
+ The pointer at the folder-resident proof store, where each stage records what it
352
+ changed, which checks ran and why it was allowed to advance. `null` until the
353
+ first stage records an entry. See
354
+ `${CLAUDE_PLUGIN_ROOT}/references/proof-bundle.md`.
355
+ - `path` — the store directory (`design-system/proof`).
356
+ - `stages` — object keyed by stage name, each holding
357
+ `{ at, adoptedAt, exempt?, fingerprint }`.
358
+ - `fingerprint` — computed over the **whole** stage file, so a hand-edited entry
359
+ is caught (`proof-stale`), the same way `docs:check` catches a hand-edited
360
+ surface.
361
+ - `at` — the latest recorded entry's timestamp. It moves with every record.
362
+ - `adoptedAt` — the first entry's timestamp: **write-once**, never rewritten, the
363
+ human-readable "when did this system opt in". No gate reads it.
364
+ - `exempt` — present only on the per-component stages (`component-builder`,
365
+ `storybook-chromatic-builder`) and **capture-once-then-shrink**: the stage's
366
+ first record captures every name then in `components.built`, and every record,
367
+ the first included, removes its own subject. The list never gains a name. It is
368
+ what scopes the per-component `proof-missing` rule to components the stage has
369
+ not yet proven, so adopting the bundle does not light up a failure for every
370
+ component built before it existed. A listed stage with **no** `exempt` key
371
+ exempts **nothing**, not everything — a missing list makes the gate louder, not
372
+ quieter.
373
+
374
+ **Pointers and hashes, never entries** — the entries themselves live in the
375
+ store, for the same reason `components.meta[name].doc` keeps content out of the
376
+ manifest. Skills write all of this only through `verify-check.mjs --record`; none
377
+ hand-writes a stage file or computes a fingerprint by hand.
378
+
379
+ No gate compares `components.meta[name].updatedAt` against `adoptedAt`. That
380
+ field is refreshed by a status promotion as well as by a rebuild, so a stage
381
+ other than the one that owes the entry can move it — and a date comparison would
382
+ fail a pre-existing component the first time an unrelated storying run promotes
383
+ it.
384
+
349
385
  ### `completedSkills`
350
386
  - Append-only list of skill identifiers that have run to completion at least
351
387
  once. Useful for the `/design-system-status` command and for friendly
@@ -379,3 +415,7 @@ are authored. Bump `schemaVersion` to `5`. No existing field changes.
379
415
  **v5 → v6 migration:** add `figma.docCardVariables` (default `null`), populated
380
416
  on the first doc-card render. Bump `schemaVersion` to `6`. No existing field
381
417
  changes.
418
+
419
+ **v6 → v7 migration:** add `verification` (default `null`), populated on the
420
+ first recorded stage entry. Bump `schemaVersion` to `7`. No existing field
421
+ changes.
@@ -0,0 +1,312 @@
1
+ # The verification proof bundle (`design-system/proof/`)
2
+
3
+ Each stage of the system leaves a durable record of what it changed, which checks
4
+ ran, what each one found, and why it was allowed to advance. That record is the
5
+ proof bundle. It exists because "run it twice, get the same structure" proves
6
+ idempotency, not quality — and because the checks that do prove quality (the
7
+ Figma read-back, a reviewer verdict, a build result) are prose today, and prose
8
+ evaporates when the run ends.
9
+
10
+ The bundle is **the record, not the enforcement mechanism**. Gates enforce:
11
+ `docs:check` owns documentation drift, the `figma-executor` read-back owns
12
+ structure, `adherence:check` owns code adherence. The bundle persists what each
13
+ gate found. The `verify:check` gate reads it, recomputes what it can off disk,
14
+ and fails when a stored result and a fresh run disagree.
15
+
16
+ ## Where it lives
17
+
18
+ `design-system/proof/<stage>.json` — one file per stage, in the user's working
19
+ folder, from the first component built. It is folder-resident exactly like
20
+ `design-system/docs/` (see
21
+ `${CLAUDE_PLUGIN_ROOT}/references/component-doc-schema.md`), so a stage can
22
+ record an entry during the Figma-only *folder* stage, before any repo exists.
23
+ The path is stable across folder→repo; files never move.
24
+
25
+ ## The file shape
26
+
27
+ `{ "stage": "<name>", "subjects": { "<subject>": <entry> } }`
28
+
29
+ A **subject** is the thing the entry is about: a component name for a
30
+ per-component stage, or the literal `"system"` for a stage that is not
31
+ per-component. One entry per stage-and-subject, latest wins — the store models
32
+ current state, not history, the same way the manifest does.
33
+
34
+ ## The entry shape
35
+
36
+ `{ at, changed, checks, screenshot?, advancedBecause }`
37
+
38
+ - `at` — ISO timestamp, **supplied by the caller**. `Date.now()` is not available
39
+ in every execution context a skill runs in, so the recorder never invents it.
40
+ - `changed` — array of one-line strings saying what this stage changed. May be
41
+ empty.
42
+ - `checks` — non-empty array of `{ name, method, result, evidence }`, where
43
+ `method` is `"derived"` or `"attested"`, `result` is `"pass"` or `"fail"`, and
44
+ `evidence` is a string.
45
+ - `checks[].accepted` — optional, and only on a check. An array of
46
+ `{ fg, bg, mode }` naming pairs a user explicitly accepted as failing. Only
47
+ `contrast-baseline` uses it today.
48
+ - `screenshot` — optional pointer: a Figma node id, or a repo-relative path.
49
+ - `advancedBecause` — one line saying why the stage was allowed to advance.
50
+
51
+ **`accepted`, and why its two readers disagree.** `fg` and `bg` are the dotted
52
+ `CONTRAST_PAIRS` paths, compared after the `normalizeText` fold, so
53
+ `color.text.onEmphasis` and `color.text.on-emphasis` are one role. `mode` is the
54
+ **Figma** mode name `token-builder` saw — `Light`, `Dark` — because at folder
55
+ stage no DTCG file exists yet to name a mode after. A reader that matches on
56
+ `mode` must translate it into that run's file label first.
57
+
58
+ Two skills read the field, and they match on different things:
59
+
60
+ - `token-sync-layer` drops from its own gate run's `color-contrast` failures
61
+ every one whose `(fg, bg, mode)` triple matches an entry. It is the only reader
62
+ that matches on the mode, because it is the only one that knows both names: it
63
+ read the Figma modes and it wrote the DTCG files.
64
+ - `storybook-chromatic-builder` compares its own live run's failures against the
65
+ `fg`/`bg` **roles alone**, to decide whether the `verify:check` it registers
66
+ carries `--skip color-contrast`. A per-mode test would decide nothing there —
67
+ `--skip` is rule-wide, so there is no way to skip contrast in Dark and keep it
68
+ in Light — and that skill could not translate a mode name anyway, having
69
+ neither read the Figma modes nor written the DTCG files.
70
+
71
+ The rule that keeps the field honest: **it is data a caller subtracts, never
72
+ something `verify-check.mjs` reads.** The gate does not know the field exists, and
73
+ an attested `fail` never switches a derived rule off inside it — that would be
74
+ the self-attestation this store exists to avoid. A triple that no longer matches
75
+ any failure is inert, which is how an acceptance retires itself once the pair is
76
+ fixed.
77
+
78
+ **Pointers, never content.** `evidence` and `screenshot` point at things; they do
79
+ not carry them. This is the same rule `components.meta[name].doc` follows, for
80
+ the same reason: the store is committed to the repo and read by humans. A diff
81
+ of a stage file should be legible, and a design system's history should not carry
82
+ inlined screenshots or file contents.
83
+
84
+ ## `derived` vs. `attested`
85
+
86
+ This is the load-bearing distinction in the whole store, and the two are not
87
+ equal.
88
+
89
+ - **`derived`** — `verify:check` recomputes the check off disk on every run. The
90
+ stored result is a **cache**. When the rerun disagrees with it, the run fails as
91
+ `proof-contradicted`. A derived result is trustworthy because it is not
92
+ trusted.
93
+ - **`attested`** — an agent observed it live, somewhere the CLI cannot look:
94
+ Figma, a build it ran, a review it received. It is printed on the report's
95
+ informational line and is **never the reason a run passes**.
96
+
97
+ The house precedent for the honest half is `edit-unverified` in
98
+ `${CLAUDE_PLUGIN_ROOT}/references/component-doc-schema.md` — `docs:check` records
99
+ what it cannot read without pretending it verified it. Attested checks are the
100
+ same posture: recorded, visible, never load-bearing.
101
+
102
+ ## The manifest pointer
103
+
104
+ `design-system.json` carries a top-level `verification` key — `null` until the
105
+ first stage records an entry. Content on disk, pointers and hashes in the
106
+ manifest, exactly as `components.meta[name].doc` does it. See
107
+ `${CLAUDE_PLUGIN_ROOT}/references/manifest-schema.md`.
108
+
109
+ ```json
110
+ {
111
+ "verification": {
112
+ "path": "design-system/proof",
113
+ "stages": {
114
+ "component-builder": {
115
+ "at": "2026-09-12T18:04:00.000Z",
116
+ "adoptedAt": "2026-09-12T17:41:00.000Z",
117
+ "exempt": ["Card"],
118
+ "fingerprint": "3f9c1a7b2d4e6058"
119
+ },
120
+ "token-sync-layer": {
121
+ "at": "2026-09-12T18:20:00.000Z",
122
+ "adoptedAt": "2026-09-12T18:20:00.000Z",
123
+ "fingerprint": "8ab240df1c9e5567"
124
+ }
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ - `path` — the store directory.
131
+ - `fingerprint` — computed over the **whole stage file**, so a hand-edited entry
132
+ is caught (`proof-stale`), the same way `docs:check` catches a hand-edited
133
+ surface.
134
+ - `at` — the timestamp of the latest entry recorded for that stage. It moves with
135
+ every record.
136
+ - `adoptedAt` — **write-once**. Set on a stage's first recorded entry and never
137
+ rewritten: the human-readable "when did this system opt in". No gate reads it.
138
+ - `exempt` — the names this stage has not yet proven. Present only on the
139
+ per-component stages; a system-wide stage omits the key entirely.
140
+
141
+ **Skills write all of this only through `verify-check.mjs --record`.** No skill
142
+ hand-writes a stage file, and none computes a fingerprint by hand — that is how
143
+ the pointer and the file are kept from drifting apart.
144
+
145
+ ### `exempt` is capture-once-then-shrink
146
+
147
+ Both halves of the rule are load-bearing, and a reader who implements only the
148
+ first grandfathers the whole adoption batch:
149
+
150
+ 1. On a stage's **first** record, `exempt` captures **every** name then in
151
+ `components.built`, sorted.
152
+ 2. On **every** record, the first included, the record's own subject is removed
153
+ from `exempt`.
154
+
155
+ The list never gains a name. It can only narrow. It is what scopes the
156
+ per-component `proof-missing` rule to components the stage has not yet proven:
157
+ an existing system that adopts the bundle does not light up with a failure for
158
+ every component built before the bundle existed, and a name leaves the list the
159
+ moment the stage actually proves that component.
160
+
161
+ Capture has to read the whole of `components.built` rather than "everything but
162
+ this subject", because a run's entire batch is already in `built` before the
163
+ record loop reaches the first component. A capture that excluded only its own
164
+ subject would permanently exempt its batch-mates — build four components on the
165
+ upgrade run and three of them are never owed proof again.
166
+
167
+ A stage listed in `verification.stages` with **no** `exempt` key at all exempts
168
+ **nothing**, not everything. A missing list makes the gate louder, not quieter.
169
+
170
+ ### No gate compares `updatedAt` against `adoptedAt`
171
+
172
+ However natural it looks, nothing in the verification gate compares
173
+ `components.meta[name].updatedAt` against `adoptedAt`. That field has more than
174
+ one writer: `storybook-chromatic-builder` refreshes it when it promotes a
175
+ component to `"stable"`, which is a different stage from the one that owes the
176
+ entry. So a single storying run drags every pre-existing component's timestamp
177
+ past `component-builder`'s adoption moment, and a date gate would fail a system
178
+ where nothing went wrong. A captured list of names cannot be moved by another
179
+ stage's write, and it is readable in the manifest: the exemption is stated
180
+ rather than inferred.
181
+
182
+ For the same reason, neither `adoptedAt` nor `exempt` is derived from the stage
183
+ file. Entries are latest-wins, so "the earliest `at` in the file" slides forward
184
+ every time the earliest subject is rebuilt.
185
+
186
+ ## The stage vocabulary (v1)
187
+
188
+ | Stage | Subject |
189
+ |---|---|
190
+ | `component-builder` | component name |
191
+ | `storybook-chromatic-builder` | component name |
192
+ | `token-sync-layer` | `"system"` |
193
+ | `token-builder` | `"system"` |
194
+
195
+ The machine-readable copy of that split is `PER_COMPONENT_STAGES` in
196
+ `scripts/lib/proof.mjs`, and the full list is `STAGES` beside it. **A change here
197
+ must change those constants** — they are what `verify:check` asks "is this stage
198
+ keyed by component?"
199
+
200
+ `/document-component` and the retrofit phases do not emit entries in v1.
201
+
202
+ ## The check names (v1)
203
+
204
+ | Check | Method | Recorded by |
205
+ |---|---|---|
206
+ | `structural-read-back` | attested | `component-builder` |
207
+ | `state-baseline` | attested | `component-builder` |
208
+ | `figma-name` | attested | `component-builder` |
209
+ | `focus-indicator` | attested | `component-builder` |
210
+ | `review` | attested | the dispatching skill |
211
+ | `build` | attested | `storybook-chromatic-builder` |
212
+ | `orphan-token` | derived | not recorded: re-derived on every `verify:check` run |
213
+ | `state-incomplete` | derived | `storybook-chromatic-builder` |
214
+ | `name-drift` | derived | `storybook-chromatic-builder` |
215
+ | `contrast-baseline` | attested | `token-builder` |
216
+ | `tokens:validate-output` | attested | `token-sync-layer` |
217
+ | `color-contrast` | derived | `token-sync-layer` |
218
+
219
+ **Two stages record derived results — `storybook-chromatic-builder` and
220
+ `token-sync-layer`** — and each qualifies for one reason and only that reason: it
221
+ runs `verify:check` before it records, so it has the result in hand rather than
222
+ asserting it. That is a property of what the stage does, not of which stage it
223
+ is; a stage that does not run the gate never qualifies. `component-builder` and
224
+ `token-builder` both run at folder stage, with no repo to scan, so they record
225
+ attested checks only.
226
+
227
+ A derived result sitting in a stage file is a cache the next run recomputes. A
228
+ disagreement is `proof-contradicted`.
229
+
230
+ ### Contrast: two checks, two methods
231
+
232
+ `contrast-baseline` and `color-contrast` ask the same question in two places,
233
+ and neither substitutes for the other.
234
+
235
+ - **`contrast-baseline`** — attested, recorded by `token-builder`. The ratio an
236
+ agent computed in Figma from the resolved variable values, per mode, at the
237
+ moment the semantic tier was built. Attested because the CLI cannot read Figma.
238
+ - **`color-contrast`** — derived, recorded by `token-sync-layer`. The same
239
+ question recomputed off the DTCG source by `verify:check`, which can read it.
240
+
241
+ This is the `state-baseline` / `state-incomplete` pairing one layer down: the
242
+ agent asserts in the place where the thing is built, the CLI re-derives
243
+ independently off disk, and a system wants both. Catching a bad pair at creation
244
+ time is the point of the first; not having to take that catch on trust is the
245
+ point of the second.
246
+
247
+ ## A worked example
248
+
249
+ One stage file with two subjects — `Button`, freshly built and reviewed, and
250
+ `Card`, rebuilt without a review:
251
+
252
+ ```json
253
+ {
254
+ "stage": "component-builder",
255
+ "subjects": {
256
+ "Button": {
257
+ "at": "2026-09-12T17:41:00.000Z",
258
+ "changed": [
259
+ "Built Button as a COMPONENT_SET with 18 variants",
260
+ "Bound fills, strokes and radius to semantic variables"
261
+ ],
262
+ "checks": [
263
+ {
264
+ "name": "structural-read-back",
265
+ "method": "attested",
266
+ "result": "pass",
267
+ "evidence": "COMPONENT_SET 41:207, 18 children, axes type/size/state"
268
+ },
269
+ {
270
+ "name": "state-baseline",
271
+ "method": "attested",
272
+ "result": "pass",
273
+ "evidence": "state axis: default, hover, focus, active, disabled"
274
+ },
275
+ {
276
+ "name": "figma-name",
277
+ "method": "attested",
278
+ "result": "pass",
279
+ "evidence": "Figma node named Button"
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
+ },
287
+ {
288
+ "name": "review",
289
+ "method": "attested",
290
+ "result": "pass",
291
+ "evidence": "approved"
292
+ }
293
+ ],
294
+ "screenshot": "41:207",
295
+ "advancedBecause": "Read-back and review both passed; every variant resolves to bound variables."
296
+ },
297
+ "Card": {
298
+ "at": "2026-09-12T18:04:00.000Z",
299
+ "changed": ["Rebuilt Card with the elevation variant axis"],
300
+ "checks": [
301
+ {
302
+ "name": "structural-read-back",
303
+ "method": "attested",
304
+ "result": "pass",
305
+ "evidence": "COMPONENT_SET 41:418, 6 children, axes elevation/padding"
306
+ }
307
+ ],
308
+ "advancedBecause": "Structure verified; no review requested for a variant-axis rebuild."
309
+ }
310
+ }
311
+ }
312
+ ```
@@ -57,39 +57,35 @@ The cause was the transform group, not the adapter concept. That configuration
57
57
  now ships as tested code at
58
58
  `${CLAUDE_PLUGIN_ROOT}/scripts/lib/sd-native.mjs`, is installed into the
59
59
  consumer's repo, and is verified end to end against a real source (195 emitted
60
- symbols matched, zero rule failures under the shipping ruleset, on both light
61
- and dark builds). The badge is back on that basis.
60
+ symbols matched, 107 of them with their magnitude also checked, zero rule
61
+ failures under the shipping ruleset, on both light and dark builds).
62
+ The badge is back on that basis. What "matched" does and does not check is in
63
+ *Verify, always* in `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`.
62
64
 
63
65
  **What the badge does not cover: nothing is compiled.** Every emitted value is
64
66
  checked to be a well-formed Swift or Kotlin *literal* — `tokens:validate-output`'s
65
67
  `invalid-literal` rule parses each one and fails on anything that is not
66
68
  (#53) — but no `swiftc` or `kotlinc` runs, so a type mismatch or a call to an
67
- undefined symbol would still pass. String-valued tokens (`fontFamily`, `string`,
68
- keyword `fontWeight`) are quoted by the module's own transforms, and a value with
69
- no native form at all — a CSS `linear-gradient(...)` — is filtered out of native
70
- output rather than emitted broken. That drop is reported as an unemitted token,
71
- not hidden.
69
+ undefined symbol would still pass. Which values the module quotes, and which it
70
+ drops for having no native form, is stated in §4 and §6 of
71
+ `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`. A dropped token is
72
+ not hidden: `tokens:validate-output` reports it as unemitted — see
73
+ `${CLAUDE_PLUGIN_ROOT}/scripts/README.md`.
72
74
 
73
75
  Since 0.17.0 that gap is measured rather than assumed on ThroughLine's side:
74
76
  this project's end-to-end runs compile what they generate — `kotlinc` typechecks
75
77
  `Tokens.kt` against Compose stubs, `swiftc -parse` checks `Tokens.swift` —
76
- against a real 322-token system. That is evidence about the adapter shipped to
78
+ against a real 318-token system. That is evidence about the adapter shipped to
77
79
  you, not a gate on your build, and it does not widen what the badge asserts
78
80
  about your output.
79
81
 
80
82
  `android-kotlin` uses the same module and stays Tier 2: its remaining unknowns
81
83
  are on the consumption side — Compose `dp`/`sp` behaviour against a real Compose
82
84
  app, resource-qualifier conventions, package layout — which building tokens does
83
- not exercise. The `dp`/`sp` split itself is no longer among them: font sizes and
84
- line heights whose role a DTCG source states — the `fontSize`, `letterSpacing`
85
- and `lineHeight` member names of §9.8's typography composite — now emit as
86
- `sp`. What remains is narrower and documented in
87
- `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`: a scale primitive
88
- **nothing references** carries no role and stays `dp`, named by a
89
- `tokens:validate-output` `unreferenced-text-sibling` advisory; and an `em`
90
- letterSpacing reaches Compose as a real `.em` TextUnit but is excluded from
91
- Swift deliberately, since letter spacing there needs a font size no constant
92
- Swift value could carry.
85
+ not exercise. Which tokens emit as `sp`, and the text-unit limits that remain on
86
+ each platform, are stated once, in §4 of
87
+ `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`, which is generated
88
+ from the module itself.
93
89
  `tokens:validate-output` remains what decides whether any adapter can be
94
90
  trusted, and re-promotion is available to any adapter that passes it against a
95
91
  real source.
@@ -144,6 +140,9 @@ emit as references to primitive vars rather than flattened literals.
144
140
  Tailwind tokens; the standalone `tailwind` adapter maps tokens into the
145
141
  Tailwind theme config directly; `mui` emits a JS theme object whose palette
146
142
  references preserve the cascade through object structure.
143
+ `tokens:validate-output` checks the generated CSS against its source too, and
144
+ only the CSS: for `tailwind`, the file declaring the custom properties, not the
145
+ theme config. See `${CLAUDE_PLUGIN_ROOT}/scripts/README.md`.
147
146
  - **Native adapters** (`ios-swift`, and generated native targets like
148
147
  Android/Kotlin): `outputReferences: false` — references resolve to literal
149
148
  values at build time, because the target language has no runtime var
@@ -154,45 +153,31 @@ emit as references to primitive vars rather than flattened literals.
154
153
 
155
154
  ## What the stock configuration gets wrong
156
155
 
157
- These are all legal in a real DTCG source, and a **stock** Style Dictionary
158
- transform group mishandles every one of them silently — emitting output that
159
- compiles and is wrong, which is why `tokens:validate-output` exists.
160
-
161
- **None of these are Style Dictionary limitations.** All four are fixed by
162
- roughly 80 lines of preprocessor and transform code, which ships as a tested
163
- module at `${CLAUDE_PLUGIN_ROOT}/scripts/lib/sd-native.mjs` and is documented in
164
- `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`. Against a real
165
- 322-token source it emitted 195 symbols that all map to a source token, 107 of
166
- them with their numeric magnitude additionally verified, with zero rule failures
167
- — colour and string values are matched by name only and are checked by no rule.
168
- **Import that module** rather than configuring a native adapter from a stock
169
- `transformGroup` or transcribing the reference doc.
170
-
171
- - **CSS expressions in a value** — `color-mix(in srgb, {color.brand.500} 12%,
172
- transparent)` is a runtime CSS construct. Style Dictionary does no colour
173
- math, so it resolves only the inner reference and leaves the function wrapper
174
- in the output. *Fix: a transform that computes the blend to a literal.*
175
- - **Dual-node tokens** — a node carrying both a `$value` and children (`text.sm`
176
- with `$value: "14px"` plus a `text.sm.lineHeight` child). Style Dictionary's
177
- resolver will not traverse into one, so every alias to the child fails to
178
- resolve and emits as a bare literal; its collector also stops there, so the
179
- child is never emitted at all. *Fix: a preprocessor that resolves aliases and
180
- hoists the children.*
181
- - **`%` and `em` dimensions** — parent-relative or container-relative, so there
182
- genuinely is no build-time native magnitude. This one is a real limit rather
183
- than a configuration gap. *Handling: filter them out of native builds — on the
184
- authored value, since a `100%` token may be typed `string`, not `dimension`.*
156
+ Real token sources carry all of these, and a **stock** Style Dictionary
157
+ transform group mishandles them silently — emitting output that compiles and is
158
+ wrong, which is why `tokens:validate-output` exists.
159
+
160
+ The native fixes ship as a tested module at
161
+ `${CLAUDE_PLUGIN_ROOT}/scripts/lib/sd-native.mjs`. **Import that module** rather
162
+ than configuring a native adapter from a stock `transformGroup` or transcribing
163
+ the reference doc. What goes wrong in each case, and exactly what the module does
164
+ about it, is stated once, in
165
+ `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`, which is generated
166
+ from the module and checked for freshness in CI. This list only points there:
167
+
168
+ - **`px`-authored dimensions** — the stock groups assume `rem` and multiply by
169
+ 16. See §1, *Read the authored unit*.
170
+ - **CSS expressions in a value**, such as `color-mix()` — see §2, *Compute
171
+ `color-mix()` to a literal*.
172
+ - **Dual-node tokens**, a node carrying both a `$value` and children — see §3,
173
+ *Resolve aliases and hoist dual-node children*.
174
+ - **`%` and `em` dimensions** — see `nativeFilter` in §4, *Assemble the platform
175
+ from the stock list*.
185
176
  - **A third mode axis** — this reference models theme (`.dark` /
186
177
  `[data-theme]`) and brand (`[data-brand]`). A viewport axis carrying its own
187
178
  spacing and type scales is common and has no mapping here; on native it is
188
179
  size classes and resource qualifiers, resolved by a different mechanism
189
- entirely.
190
-
191
- **Native dimension transforms must read the authored unit.** The stock
192
- `ios-swift` and `compose` transform groups assume `rem` input and multiply by
193
- 16. Against a `px`-authored source that silently produces output at sixteen
194
- times scale which compiles and ships. Emit 1:1 for `px` and unitless ratios;
195
- ×16 only for `rem`.
180
+ entirely. The module does not address this one.
196
181
 
197
182
  ## Brownfield value transforms
198
183