@radicool/throughline 0.24.0 → 0.26.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 (62) hide show
  1. package/README.md +8 -4
  2. package/adapters/codex/AGENTS.md +2 -0
  3. package/adapters/codex/prompts/check-figma.md +41 -0
  4. package/adapters/codex/prompts/component-builder.md +5 -3
  5. package/adapters/codex/prompts/compose-figma.md +59 -0
  6. package/adapters/codex/prompts/document-component.md +3 -2
  7. package/adapters/cursor/.cursor/commands/check-figma.md +41 -0
  8. package/adapters/cursor/.cursor/commands/compose-figma.md +59 -0
  9. package/adapters/cursor/.cursor/commands/document-component.md +3 -2
  10. package/adapters/cursor/.cursor/rules/component-builder.mdc +5 -3
  11. package/adapters/generic/AGENTS.md +2 -0
  12. package/adapters/generic/commands/check-figma.md +41 -0
  13. package/adapters/generic/commands/compose-figma.md +59 -0
  14. package/adapters/generic/commands/document-component.md +3 -2
  15. package/adapters/generic/skills/component-builder/SKILL.md +5 -3
  16. package/package.json +1 -1
  17. package/references/doc-card-builder.md +62 -15
  18. package/references/figma-adherence.md +463 -0
  19. package/references/figma-checks.md +106 -43
  20. package/references/figma-compose.md +896 -0
  21. package/scripts/README.md +8 -2
  22. package/scripts/build-doc-card-builder.mjs +16 -0
  23. package/scripts/build-figma-checks.mjs +208 -23
  24. package/scripts/doc-card-payload.mjs +115 -0
  25. package/scripts/install.mjs +7 -1
  26. package/scripts/lib/doc-card-plan.mjs +37 -0
  27. package/scripts/lib/doc-card-render.figma.js +9 -15
  28. package/scripts/lib/figma-adherence-read.figma.js +228 -0
  29. package/scripts/lib/figma-adherence.mjs +146 -0
  30. package/scripts/lib/figma-checks-read.figma.js +43 -43
  31. package/scripts/lib/figma-checks.mjs +16 -5
  32. package/scripts/lib/figma-compose.figma.js +326 -0
  33. package/scripts/lib/figma-compose.mjs +317 -0
  34. package/scripts/lib/figma-variables-read.figma.js +39 -0
  35. package/scripts/lib/fixtures/figma-adherence/clean.json +90 -0
  36. package/scripts/lib/fixtures/figma-adherence/detached-library.json +116 -0
  37. package/scripts/lib/fixtures/figma-adherence/ds01-gaps.json +84 -0
  38. package/scripts/lib/fixtures/figma-adherence/inert-all.json +45 -0
  39. package/scripts/lib/fixtures/figma-adherence/mixed-text.json +61 -0
  40. package/scripts/lib/fixtures/figma-adherence/nothing-scanned.json +49 -0
  41. package/scripts/lib/fixtures/figma-adherence/off-scale-gap.json +65 -0
  42. package/scripts/lib/fixtures/figma-adherence/override-raw.json +72 -0
  43. package/scripts/lib/fixtures/figma-adherence/padding-raw.json +74 -0
  44. package/scripts/lib/fixtures/figma-adherence/raw-colour-token.json +70 -0
  45. package/scripts/lib/fixtures/figma-adherence/root-radius.json +88 -0
  46. package/scripts/lib/fixtures/figma-adherence/skip-colour.json +86 -0
  47. package/scripts/lib/fixtures/figma-adherence/zygarden-override-bound.json +85 -0
  48. package/scripts/lib/fixtures/figma-adherence/zygarden-text-raw.json +208 -0
  49. package/scripts/lib/fixtures/figma-compose/casing-and-suffix.json +8 -0
  50. package/scripts/lib/fixtures/figma-compose/fill-in-unlaid-slot.json +7 -0
  51. package/scripts/lib/fixtures/figma-compose/hug-on-fixed-component.json +34 -0
  52. package/scripts/lib/fixtures/figma-compose/inventory.json +295 -0
  53. package/scripts/lib/fixtures/figma-compose/many-errors.json +8 -0
  54. package/scripts/lib/fixtures/figma-compose/missing-component.json +7 -0
  55. package/scripts/lib/fixtures/figma-compose/name-taken.json +6 -0
  56. package/scripts/lib/fixtures/figma-compose/padding-sides.json +23 -0
  57. package/scripts/lib/fixtures/figma-compose/radius-kind.json +27 -0
  58. package/scripts/lib/fixtures/figma-compose/raw-number.json +5 -0
  59. package/scripts/lib/fixtures/figma-compose/sign-in-ok.json +16 -0
  60. package/scripts/lib/fixtures/figma-compose/slot-ok.json +9 -0
  61. package/scripts/lib/fixtures/figma-compose/unknown-prop-and-option.json +7 -0
  62. package/scripts/lib/fixtures/figma-compose/wrong-kind.json +5 -0
package/scripts/README.md CHANGED
@@ -25,13 +25,19 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
25
25
  | `lib/doc-card-render.figma.js` | Figma renderer template for the doc card's `Usage` band and header. Inlined into `references/doc-card-builder.md`; never executed as a module. | plugin-internal (not installed) |
26
26
  | `lib/figma-checks.mjs` | Pure checker for the Figma stop conditions: the COMPONENT_SET assertion against the full matrix, the state matrix, the focus ring, and the focus ring's 3:1 contrast against the surfaces it's named to sit on, plus the focus-framework rules. Inlined into `references/figma-checks.md`; unit-tested against `lib/fixtures/figma-checks/`. | plugin-internal (not installed) |
27
27
  | `lib/figma-checks-read.figma.js` | Figma reader that turns the live COMPONENT_SET into the checker's snapshot JSON. Inlined into `references/figma-checks.md`; never executed as a module. | plugin-internal (not installed) |
28
+ | `lib/figma-variables-read.figma.js` | Figma helpers that resolve a colour variable's value in each mode, through aliases. Inlined into `references/figma-checks.md`, `references/figma-adherence.md` and `references/figma-compose.md`; never executed as a module. | plugin-internal (not installed) |
29
+ | `lib/figma-adherence.mjs` | Pure checker for the Figma screen adherence gate: detached components, and raw colours, gaps, padding and radii that a variable has. A raw value no variable has is an advisory. Inlined into `references/figma-adherence.md`; unit-tested against `lib/fixtures/figma-adherence/`. | plugin-internal (not installed) |
30
+ | `lib/figma-adherence-read.figma.js` | Figma reader that turns the screens on one page into the adherence checker's snapshot JSON, read-only, including content added to an instance's slots. Inlined into `references/figma-adherence.md`; never executed as a module. | plugin-internal (not installed) |
31
+ | `lib/figma-compose.mjs` | Pure resolver for composing a Figma screen: checks a screen description against the file's component sets, variables and text styles, and returns every error or a plan of real keys and ids. Inlined into `references/figma-compose.md`; unit-tested against `lib/fixtures/figma-compose/`. | plugin-internal (not installed) |
32
+ | `lib/figma-compose.figma.js` | Figma reader and writer for screen composition: reads the inventory, builds a `WIP:` screen from the resolver's plan with every value bound, and finalizes or discards it. Inlined into `references/figma-compose.md`; never executed as a module. | plugin-internal (not installed) |
28
33
  | `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 |
29
34
  | `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` |
30
35
  | `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` |
31
36
  | `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` |
32
37
  | `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. Also inlined into `references/figma-checks.md` for the focus-ring contrast check. | copied alongside `verify-check.mjs`; also inlined into the generated Figma checks |
38
+ | `doc-card-payload.mjs` | Assemble the complete `figma_execute` body that renders one doc card — record, canonical fingerprint, the nine `figma.docCardVariables` lookups, `Body/Default`, the generated snippet and the call — and print the `renderHash` the render must return, so a payload that changed on the way in is caught. Reads the snippet from `../references/doc-card-builder.md`. | run from the plugin (or `.throughline/scripts`); not copied into consumer repos |
33
39
  | `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) |
34
- | `build-figma-checks.mjs` | Generate `references/figma-checks.md`, the `figma_execute` snippet for the Figma stop conditions, by inlining `lib/component-states.mjs`, `lib/contrast.mjs` and `lib/figma-checks.mjs` above the reader (`lib/figma-checks-read.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
40
+ | `build-figma-checks.mjs` | Generate `references/figma-checks.md`, the `figma_execute` snippet for the Figma stop conditions, by inlining `lib/component-states.mjs`, `lib/contrast.mjs`, `lib/figma-checks.mjs` and `lib/figma-variables-read.figma.js` above the reader (`lib/figma-checks-read.figma.js`); `references/figma-adherence.md`, the screen adherence gate, from `lib/figma-adherence.mjs`, `lib/figma-variables-read.figma.js` and `lib/figma-adherence-read.figma.js`; and `references/figma-compose.md`, screen composition, from `lib/figma-adherence.mjs`, `lib/figma-compose.mjs`, `lib/figma-variables-read.figma.js` and `lib/figma-compose.figma.js`. `--check` gates CI on all three. | plugin-internal (not installed) |
35
41
  | `build-state-baseline.mjs` | Generate `references/state-baseline.md`, the table of which components owe which interaction states, from `lib/component-states.mjs`. The Figma executor reads the table; `verify:check` reads the module. `--check` gates CI. | plugin-internal (not installed) |
36
42
  | `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) |
37
43
 
@@ -249,7 +255,7 @@ the committed `adapters/<target>/` tree plus the runtime payload
249
255
  (`references/` + `scripts/`, minus `scripts/adapters/`) into `.throughline/`,
250
256
  rewriting `${CLAUDE_PLUGIN_ROOT}` → `.throughline`:
251
257
 
252
- npx @radicool/throughline@0.24.0 init --target=cursor|codex|generic
258
+ npx @radicool/throughline@0.26.0 init --target=cursor|codex|generic
253
259
 
254
260
  See `scripts/install.mjs` (pure core + CLI + `install.test.mjs`).
255
261
 
@@ -29,6 +29,18 @@ const HEADER = [
29
29
  '',
30
30
  '## How to call it',
31
31
  '',
32
+ 'Steps 1–4 are one command once `figma.docCardVariables` is recorded:',
33
+ '',
34
+ '```sh',
35
+ 'node scripts/doc-card-payload.mjs design-system/docs/components/<Name>.doc.json <cardNodeId> --out <scratch>/payload.js',
36
+ '```',
37
+ '',
38
+ 'It writes the complete `figma_execute` body — record, fingerprint, the nine',
39
+ 'role lookups by recorded name, `Body/Default`, the snippet and the call — and',
40
+ 'prints the `expectedRenderHash` to stderr. Pass the file\'s contents as the',
41
+ '`code` unchanged, then do steps 5–6. The steps below are what it does, and',
42
+ 'what to do by hand when the mapping isn\'t recorded yet.',
43
+ '',
32
44
  '1. Load the record and compute its canonical fingerprint in Node',
33
45
  ' (`canonicalFingerprint` in `scripts/lib/doc-record.mjs`).',
34
46
  '2. Read `figma.docCardVariables` from `design-system.json`.',
@@ -97,6 +109,10 @@ const HEADER = [
97
109
  ' `cardWidth` — not from a screenshot, then stamp the manifest from it:',
98
110
  ' `surfaces.docCard = { src: summary.fingerprint, render: summary.renderHash,',
99
111
  ' renderer: summary.rendererVersion }`. Never re-read the card to stamp.',
112
+ ' With the payload script, first check `summary.renderHash` equals the',
113
+ ' `expectedRenderHash` it printed. A mismatch means the payload changed on',
114
+ ' the way in, or `Body/Default` isn\'t the size the script assumed — the',
115
+ ' summary\'s `bodyFontSize` says which; re-run with `--font-size`.',
100
116
  '',
101
117
  '## The snippet',
102
118
  '',
@@ -1,10 +1,18 @@
1
- // Generates references/figma-checks.md — the canonical figma_execute snippet
2
- // that runs the Figma stop conditions (COMPONENT_SET assertion, state matrix,
3
- // focus ring, focus-ring contrast) — by inlining the state table
4
- // (lib/component-states.mjs), the contrast maths (lib/contrast.mjs) and the
5
- // pure checker (lib/figma-checks.mjs) above the Figma reader
6
- // (lib/figma-checks-read.figma.js). Mirrors build-doc-card-builder.mjs: run
7
- // bare to write, run with --check to gate CI. Zero dependencies.
1
+ // Generates the two canonical figma_execute snippets by inlining tested Node
2
+ // modules above their Figma readers:
3
+ // references/figma-checks.md — the Figma stop conditions (COMPONENT_SET
4
+ // assertion, state matrix, focus ring, focus-ring contrast): the state table
5
+ // (lib/component-states.mjs), the contrast maths (lib/contrast.mjs) and the pure
6
+ // checker (lib/figma-checks.mjs), then lib/figma-variables-read.figma.js and the
7
+ // reader (lib/figma-checks-read.figma.js).
8
+ // references/figma-adherence.md — the screen adherence gate: the pure checker
9
+ // (lib/figma-adherence.mjs), then lib/figma-variables-read.figma.js and the
10
+ // screen reader (lib/figma-adherence-read.figma.js).
11
+ // references/figma-compose.md — screen composition: lib/figma-adherence.mjs (for
12
+ // dimensionBuckets), the pure resolver (lib/figma-compose.mjs),
13
+ // lib/figma-variables-read.figma.js, then the writer (lib/figma-compose.figma.js).
14
+ // Mirrors build-doc-card-builder.mjs: run bare to write all three, run with --check
15
+ // to gate CI on all three. Zero dependencies.
8
16
  import { readFileSync, writeFileSync } from 'node:fs';
9
17
  import { join, dirname } from 'node:path';
10
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -14,9 +22,16 @@ const STATES = join(REPO_ROOT, 'scripts', 'lib', 'component-states.mjs');
14
22
  const CONTRAST = join(REPO_ROOT, 'scripts', 'lib', 'contrast.mjs');
15
23
  const CHECKER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks.mjs');
16
24
  const READER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks-read.figma.js');
25
+ const VARIABLES = join(REPO_ROOT, 'scripts', 'lib', 'figma-variables-read.figma.js');
26
+ const ADHERENCE = join(REPO_ROOT, 'scripts', 'lib', 'figma-adherence.mjs');
27
+ const ADHERENCE_READER = join(REPO_ROOT, 'scripts', 'lib', 'figma-adherence-read.figma.js');
28
+ const COMPOSE = join(REPO_ROOT, 'scripts', 'lib', 'figma-compose.mjs');
29
+ const COMPOSE_WRITER = join(REPO_ROOT, 'scripts', 'lib', 'figma-compose.figma.js');
17
30
  const OUT = join(REPO_ROOT, 'references', 'figma-checks.md');
31
+ const ADHERENCE_OUT = join(REPO_ROOT, 'references', 'figma-adherence.md');
32
+ const COMPOSE_OUT = join(REPO_ROOT, 'references', 'figma-compose.md');
18
33
 
19
- const BIND = 'const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });';
34
+ const BIND = 'const { checkSpec, checkBuilt, checkExisting } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });';
20
35
 
21
36
  const HEADER = [
22
37
  '# Figma stop-condition checks (GENERATED)',
@@ -45,7 +60,7 @@ const HEADER = [
45
60
  ' component sits on, copied from the architect\'s spec. Without it the',
46
61
  ' focus-contrast check is skipped. Surfaces are looked up among the file\'s',
47
62
  ' local variables only.',
48
- ' - `const MODE = \'spec\'` or `\'built\'`.',
63
+ ' - `const MODE = \'spec\'`, `\'built\'` or `\'existing\'`.',
49
64
  '2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass',
50
65
  ' `timeout: 30000`.',
51
66
  '3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is',
@@ -59,6 +74,18 @@ const HEADER = [
59
74
  ' COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs',
60
75
  ' in the self-verify loop. It throws only if that frame is missing; a frame with',
61
76
  ' no set, or a set Figma reports as conflicting, fails the structural check.',
77
+ '5. `\'existing\'` checks a set already in the library, read-only: the first',
78
+ ' COMPONENT_SET whose name matches `name` ignoring case and punctuation. Add',
79
+ ' `page` to `EXPECT` (the page holding the components) so only that page is',
80
+ ' loaded; without it every page loads in turn, which on a large file can stall',
81
+ ' the bridge. `matrix` is not needed. There is no spec to copy `controlLayer` and',
82
+ ' `surfaces` from, so the caller names them: `surfaces` as for a build, and',
83
+ ' `controlLayer` for a wrapped control whose ring sits on an inner layer (without',
84
+ ' it, a correct ring on an inner layer reads as missing). It runs the state,',
85
+ ' focus-ring and focus-contrast checks against the set\'s own variants, not the',
86
+ ' structural check. A set whose focus state is drawn some other way, such as an',
87
+ ' INSIDE stroke, fails the focus-ring check and skips contrast. It throws when no',
88
+ ' set has that name.',
62
89
  '',
63
90
  '```js',
64
91
  'const EXPECT = { name: \'Chip\', matrix: { state: [\'default\', \'hover\'] }, uiFramework: \'shadcn\' };',
@@ -72,21 +99,155 @@ const HEADER = [
72
99
  '```js',
73
100
  ].join('\n');
74
101
 
102
+ const ADHERENCE_HEADER = [
103
+ '# Figma screen adherence gate (GENERATED)',
104
+ '',
105
+ '> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/figma-adherence.mjs`',
106
+ '> (the pure checker, unit-tested in Node), `scripts/lib/figma-variables-read.figma.js`',
107
+ '> (colour-variable resolution) + `scripts/lib/figma-adherence-read.figma.js` (the',
108
+ '> screen reader). Regenerate with `node scripts/build-figma-checks.mjs`; CI gates',
109
+ '> freshness with `--check`.',
110
+ '',
111
+ 'The canonical `figma_execute` snippet that checks Figma screens against the design',
112
+ 'system, read-only. It fails a frame detached from a component, a raw colour that a',
113
+ 'colour variable has (opacity included), and a raw gap, padding or corner radius',
114
+ 'that a spacing or radius variable has, naming the variable to bind. A number',
115
+ 'variable counts by its scope (`GAP`, `CORNER_RADIUS`), or, left at `ALL_SCOPES`,',
116
+ 'by a name that says spacing or radius. A raw value no variable has is an',
117
+ 'advisory, not a failure. A rule that had nothing to compare against fails by name',
118
+ '(`nothing-scanned`, `colour-rule-inert`, `dimension-rule-inert`). The script is the',
119
+ 'rule; do not re-write it by hand.',
120
+ '',
121
+ '## How to call it',
122
+ '',
123
+ '1. Paste the snippet below into `figma_execute`, with the two slots filled above it:',
124
+ ' - `const TARGET = { page, screens, fileKey }` — `page` is the page name (an exact',
125
+ ' match, or else the one page whose name matches once leading and trailing',
126
+ ' spaces are trimmed); `screens` is an optional list of screen names (top-level',
127
+ ' frames, or frames directly inside a top-level section), and without it every',
128
+ ' screen on the page is read; `fileKey` is `figma.fileKey` from',
129
+ ' `design-system.json`, used for the links.',
130
+ ' - `const SKIP = []` — rule names to switch off (`detached-component`,',
131
+ ' `raw-colour`, `raw-dimension`); a skipped rule\'s inert check is skipped too.',
132
+ '2. End the script with `return await runScreenGate(TARGET, SKIP);` and pass',
133
+ ' `timeout: 30000`.',
134
+ '3. Read the result, `{ pass, failures, advisories, counts, skipped }`. Each failure',
135
+ ' and advisory has `rule`, `path`, `detail` and a `link` to the node. Report every',
136
+ ' failure with its link. Never edit the file to make it pass.',
137
+ '4. Only the named page is loaded, and tokens are the file\'s local variables. A file',
138
+ ' whose variables all come from a published library fails as inert.',
139
+ '',
140
+ '```js',
141
+ 'const TARGET = { page: \'Templates\', fileKey: \'<figma.fileKey>\' };',
142
+ 'const SKIP = [];',
143
+ '// … the generated snippet …',
144
+ 'return await runScreenGate(TARGET, SKIP);',
145
+ '```',
146
+ '',
147
+ '## The snippet',
148
+ '',
149
+ '```js',
150
+ ].join('\n');
151
+
152
+ const COMPOSE_HEADER = [
153
+ '# Figma screen composition (GENERATED)',
154
+ '',
155
+ '> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/figma-adherence.mjs`',
156
+ '> (for `dimensionBuckets`), `scripts/lib/figma-compose.mjs` (the pure resolver,',
157
+ '> unit-tested in Node), `scripts/lib/figma-variables-read.figma.js` +',
158
+ '> `scripts/lib/figma-compose.figma.js` (the reader and writer). Regenerate with',
159
+ '> `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.',
160
+ '',
161
+ 'The canonical `figma_execute` snippet that composes a screen from the file\'s own',
162
+ 'component sets, variables and text styles. A screen is a short JSON description',
163
+ 'written only in those names (its shape is the `DESCRIPTION` comment in the',
164
+ 'snippet, at the top of the resolver).',
165
+ 'The whole description is checked before anything is written: an unknown component,',
166
+ 'property, option, variable or text style, a variable of the wrong kind, or a name',
167
+ 'already held by a screen a person made, comes back as a list of errors and the file',
168
+ 'is untouched. A component the file lacks is a `missing-component` error with the',
169
+ 'nearest names: report it as a gap, never draw a lookalike. Every value the script',
170
+ 'writes is bound to a variable. The script is the rule; do not re-write it by hand.',
171
+ '',
172
+ '## How to call it',
173
+ '',
174
+ '1. Paste the snippet below into `figma_execute` with `timeout: 30000`, and end it',
175
+ ' with `return await runCompose(MODE, SPEC);`, one mode per call:',
176
+ ' - `\'inventory\'`, `SPEC = { componentsPage, iconsPage }`: read-only. Returns',
177
+ ' `{ summary }`: each set\'s properties and options and whether it has auto',
178
+ ' layout, swap targets, variables by kind (colour, gap, radius), text styles and',
179
+ ' modes. Plan only from these names.',
180
+ ' `iconsPage` is optional; pass it when a swap property needs its targets.',
181
+ ' - `\'build\'`, `SPEC = { componentsPage, iconsPage, description, fileKey }`:',
182
+ ' returns `{ ok: false, errors }` with nothing written, or builds the screen into',
183
+ ' a top-level frame `WIP: <screen>` on the description\'s page (created if',
184
+ ' missing) and returns `{ ok: true, wipId, wipName, link, ms }`. A throw during',
185
+ ' the write removes the `WIP:` frame (and a page the build created) before it',
186
+ ' rethrows, so nothing is left half-built: report it as a composer bug.',
187
+ ' - `\'finalize\'`, `SPEC = { page, screen, fileKey }`: renames `WIP: <screen>` to',
188
+ ' `<screen>`, replacing a screen of that name only if throughline composed it.',
189
+ ' Returns `{ id, link, replaced }`.',
190
+ ' - `\'discard\'`, `SPEC = { page, screen }`: removes `WIP: <screen>`. Only when',
191
+ ' the user asks.',
192
+ '2. Between `build` and `finalize`, run the screen gate',
193
+ ' (`references/figma-adherence.md`) with `screens: [\'WIP: <screen>\']`. Finalize',
194
+ ' only a passing screen. A failure is a composer bug: report it and leave the',
195
+ ' `WIP:` frame.',
196
+ '3. Pages are found by exact name, or else the one page whose trimmed name matches.',
197
+ ' Only the named pages are loaded. Components and variables are the file\'s local',
198
+ ' ones.',
199
+ '',
200
+ '```js',
201
+ 'const MODE = \'build\';',
202
+ 'const SPEC = { componentsPage: \'Components\', iconsPage: \'Icons\', fileKey: \'<figma.fileKey>\', description: {',
203
+ ' screen: \'Sign in\', page: \'Screens\', width: 390, modes: { \'Color / Semantic\': \'Light\' },',
204
+ ' root: { layout: \'vertical\', gap: \'space/gap/md\', padding: \'space/inset/lg\', fill: \'color/bg/canvas\', children: [',
205
+ ' { text: \'Sign in\', style: \'Heading/L\', color: \'color/text/primary\' },',
206
+ ' { component: \'Input\', props: { placeholder: \'Email\' }, sizing: { horizontal: \'fill\' } },',
207
+ ' { component: \'Button\', props: { label: \'Sign in\', hasTrailing: true, trailingIcon: \'icon/arrow-right\' } },',
208
+ ' ] },',
209
+ '} };',
210
+ '// … the generated snippet …',
211
+ 'return await runCompose(MODE, SPEC);',
212
+ '```',
213
+ '',
214
+ '## The snippet',
215
+ '',
216
+ '```js',
217
+ ].join('\n');
218
+
75
219
  const FOOTER = ['```', ''].join('\n');
76
220
 
77
221
  const stripExports = (src) =>
78
222
  src.replace(/^export const /gm, 'const ').replace(/^export function /gm, 'function ');
79
223
 
80
- export function buildFigmaChecks({ statesSource, contrastSource, checkerSource, readerSource }) {
81
- const states = stripExports(statesSource);
82
- const contrast = stripExports(contrastSource);
83
- const checker = stripExports(checkerSource);
84
- for (const [name, src] of [['component-states.mjs', states], ['contrast.mjs', contrast], ['figma-checks.mjs', checker], ['figma-checks-read.figma.js', readerSource]]) {
224
+ function assertInlinable(sources) {
225
+ for (const [name, src] of sources) {
85
226
  if (/^\s*(import|export)\b/m.test(src)) {
86
227
  throw new Error(`${name} must stay import-free (only top-level \`export const\`/\`export function\` allowed) — it is inlined into the Figma snippet where no module system exists`);
87
228
  }
88
229
  }
89
- return `${HEADER}\n${states}\n${contrast}\n${checker}\n${BIND}\n\n${readerSource}${FOOTER}`;
230
+ }
231
+
232
+ export function buildFigmaChecks({ statesSource, contrastSource, checkerSource, variablesSource, readerSource }) {
233
+ const states = stripExports(statesSource);
234
+ const contrast = stripExports(contrastSource);
235
+ const checker = stripExports(checkerSource);
236
+ assertInlinable([['component-states.mjs', states], ['contrast.mjs', contrast], ['figma-checks.mjs', checker], ['figma-variables-read.figma.js', variablesSource], ['figma-checks-read.figma.js', readerSource]]);
237
+ return `${HEADER}\n${states}\n${contrast}\n${checker}\n${BIND}\n\n${variablesSource}\n${readerSource}${FOOTER}`;
238
+ }
239
+
240
+ export function buildFigmaAdherence({ checkerSource, variablesSource, readerSource }) {
241
+ const checker = stripExports(checkerSource);
242
+ assertInlinable([['figma-adherence.mjs', checker], ['figma-variables-read.figma.js', variablesSource], ['figma-adherence-read.figma.js', readerSource]]);
243
+ return `${ADHERENCE_HEADER}\n${checker}\n${variablesSource}\n${readerSource}${FOOTER}`;
244
+ }
245
+
246
+ export function buildFigmaCompose({ adherenceSource, composeSource, variablesSource, writerSource }) {
247
+ const adherence = stripExports(adherenceSource);
248
+ const compose = stripExports(composeSource);
249
+ assertInlinable([['figma-adherence.mjs', adherence], ['figma-compose.mjs', compose], ['figma-variables-read.figma.js', variablesSource], ['figma-compose.figma.js', writerSource]]);
250
+ return `${COMPOSE_HEADER}\n${adherence}\n${compose}\n${variablesSource}\n${writerSource}${FOOTER}`;
90
251
  }
91
252
 
92
253
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -94,18 +255,42 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
94
255
  statesSource: readFileSync(STATES, 'utf8'),
95
256
  contrastSource: readFileSync(CONTRAST, 'utf8'),
96
257
  checkerSource: readFileSync(CHECKER, 'utf8'),
258
+ variablesSource: readFileSync(VARIABLES, 'utf8'),
97
259
  readerSource: readFileSync(READER, 'utf8'),
98
260
  });
261
+ const adherence = buildFigmaAdherence({
262
+ checkerSource: readFileSync(ADHERENCE, 'utf8'),
263
+ variablesSource: readFileSync(VARIABLES, 'utf8'),
264
+ readerSource: readFileSync(ADHERENCE_READER, 'utf8'),
265
+ });
266
+ const compose = buildFigmaCompose({
267
+ adherenceSource: readFileSync(ADHERENCE, 'utf8'),
268
+ composeSource: readFileSync(COMPOSE, 'utf8'),
269
+ variablesSource: readFileSync(VARIABLES, 'utf8'),
270
+ writerSource: readFileSync(COMPOSE_WRITER, 'utf8'),
271
+ });
272
+ const outputs = [
273
+ [OUT, result, 'references/figma-checks.md', 'figma checks'],
274
+ [ADHERENCE_OUT, adherence, 'references/figma-adherence.md', 'figma adherence'],
275
+ [COMPOSE_OUT, compose, 'references/figma-compose.md', 'figma compose'],
276
+ ];
99
277
  if (process.argv.includes('--check')) {
100
- let onDisk = null;
101
- try { onDisk = readFileSync(OUT, 'utf8'); } catch { /* missing counts as drift */ }
102
- if (onDisk !== result) {
103
- console.error('✗ references/figma-checks.md out of date; run: node scripts/build-figma-checks.mjs');
104
- process.exit(1);
278
+ let stale = false;
279
+ for (const [path, content, label, what] of outputs) {
280
+ let onDisk = null;
281
+ try { onDisk = readFileSync(path, 'utf8'); } catch { /* missing counts as drift */ }
282
+ if (onDisk !== content) {
283
+ console.error(`✗ ${label} out of date; run: node scripts/build-figma-checks.mjs`);
284
+ stale = true;
285
+ } else {
286
+ console.log(`✓ ${what} in sync`);
287
+ }
105
288
  }
106
- console.log('✓ figma checks in sync');
289
+ if (stale) process.exit(1);
107
290
  } else {
108
- writeFileSync(OUT, result);
109
- console.log('✓ wrote references/figma-checks.md');
291
+ for (const [path, content, label] of outputs) {
292
+ writeFileSync(path, content);
293
+ console.log(`✓ wrote ${label}`);
294
+ }
110
295
  }
111
296
  }
@@ -0,0 +1,115 @@
1
+ // Assembles the complete figma_execute payload that renders one component doc
2
+ // card — steps 1–4 of "How to call it" in references/doc-card-builder.md — so
3
+ // the caller passes one file's contents instead of hand-splicing the record,
4
+ // fingerprint, variable lookups and generated snippet. Also prints the
5
+ // renderHash the render must return: the payload is pasted into the tool call,
6
+ // and a matching hash proves it arrived intact.
7
+ //
8
+ // node scripts/doc-card-payload.mjs <record.doc.json> <cardNodeId>
9
+ // [--manifest design-system.json] [--out payload.js] [--font-size 16]
10
+ //
11
+ // Zero dependencies. Reads the generated snippet from ../references, which sits
12
+ // beside scripts/ both in the plugin and in an adapter install's .throughline/.
13
+ import { readFileSync, writeFileSync } from 'node:fs';
14
+ import { join, dirname, resolve } from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { canonicalFingerprint } from './lib/doc-record.mjs';
17
+ import { planDocCard, fnv1a } from './lib/doc-card-plan.mjs';
18
+
19
+ const BUILDER = join(dirname(fileURLToPath(import.meta.url)), '..', 'references', 'doc-card-builder.md');
20
+
21
+ const ROLES = [
22
+ 'textDefault', 'textMuted', 'tonePositive', 'toneNegative', 'border',
23
+ 'spacePadding', 'spaceRowGap', 'spaceBlockGap', 'spaceItemGap',
24
+ ];
25
+
26
+ // The text between "## The snippet"'s opening ```js fence and its closing fence.
27
+ export function extractSnippet(builderMarkdown) {
28
+ const section = builderMarkdown.split('\n## The snippet\n')[1];
29
+ if (!section) throw new Error('doc-card-builder.md has no "## The snippet" section');
30
+ const start = section.indexOf('```js\n');
31
+ const end = section.lastIndexOf('\n```');
32
+ if (start === -1 || end <= start) throw new Error('doc-card-builder.md: snippet fence not found');
33
+ return section.slice(start + '```js\n'.length, end + 1);
34
+ }
35
+
36
+ export function buildPayload({ record, cardNodeId, manifest, builderMarkdown, fontSize = 16 }) {
37
+ if (!/^\d+:\d+$/.test(cardNodeId)) throw new Error(`cardNodeId must look like "24:773", got "${cardNodeId}"`);
38
+ const names = manifest && manifest.figma && manifest.figma.docCardVariables;
39
+ if (!names) {
40
+ throw new Error('design-system.json has no figma.docCardVariables — recover the nine roles from an existing card '
41
+ + '(step 2 of "How to call it" in references/doc-card-builder.md), write them back, then re-run');
42
+ }
43
+ const missing = ROLES.filter((r) => typeof names[r] !== 'string' || names[r] === '');
44
+ if (missing.length) throw new Error('figma.docCardVariables is missing role(s): ' + missing.join(', '));
45
+
46
+ const fingerprint = canonicalFingerprint(record);
47
+ const roleNames = Object.fromEntries(ROLES.map((r) => [r, names[r]]));
48
+ const code = [
49
+ `const RECORD = ${JSON.stringify(record)};`,
50
+ `const CANONICAL_FP = '${fingerprint}';`,
51
+ extractSnippet(builderMarkdown),
52
+ `const ROLE_NAMES = ${JSON.stringify(roleNames)};`,
53
+ 'const allVars = await figma.variables.getLocalVariablesAsync();',
54
+ 'const vars = {};',
55
+ 'for (const [role, name] of Object.entries(ROLE_NAMES)) {',
56
+ ' const hits = allVars.filter((v) => v.name === name);',
57
+ " if (hits.length !== 1) throw new Error('docCardVariables.' + role + ' \"' + name + '\" resolves to ' + hits.length + ' variables, not exactly one');",
58
+ ' vars[role] = await figma.variables.getVariableByIdAsync(hits[0].id);',
59
+ '}',
60
+ "const bodyTextStyle = (await figma.getLocalTextStylesAsync()).find((s) => s.name === 'Body/Default');",
61
+ `const card = await figma.getNodeByIdAsync('${cardNodeId}');`,
62
+ `if (!card) throw new Error('no node ${cardNodeId} in this file');`,
63
+ 'const summary = await renderDocCard({ card, record: RECORD, vars, bodyTextStyle });',
64
+ 'return { ...summary, bodyFontSize: bodyTextStyle.fontSize };',
65
+ '',
66
+ ].join('\n');
67
+
68
+ return {
69
+ code,
70
+ fingerprint,
71
+ fontSize,
72
+ expectedRenderHash: fnv1a(JSON.stringify(planDocCard(record, { fontSize }))),
73
+ };
74
+ }
75
+
76
+ function parseArgs(argv) {
77
+ const positional = [];
78
+ const opts = { manifest: 'design-system.json', out: null, fontSize: 16 };
79
+ for (let i = 0; i < argv.length; i++) {
80
+ const a = argv[i];
81
+ if (a === '--manifest') opts.manifest = argv[++i];
82
+ else if (a === '--out') opts.out = argv[++i];
83
+ else if (a === '--font-size') opts.fontSize = Number(argv[++i]);
84
+ else positional.push(a);
85
+ }
86
+ if (positional.length !== 2 || !Number.isFinite(opts.fontSize)) {
87
+ throw new Error('usage: node scripts/doc-card-payload.mjs <record.doc.json> <cardNodeId> [--manifest design-system.json] [--out payload.js] [--font-size 16]');
88
+ }
89
+ return { recordPath: positional[0], cardNodeId: positional[1], ...opts };
90
+ }
91
+
92
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
93
+ try {
94
+ const args = parseArgs(process.argv.slice(2));
95
+ const result = buildPayload({
96
+ record: JSON.parse(readFileSync(resolve(args.recordPath), 'utf8')),
97
+ cardNodeId: args.cardNodeId,
98
+ manifest: JSON.parse(readFileSync(resolve(args.manifest), 'utf8')),
99
+ builderMarkdown: readFileSync(BUILDER, 'utf8'),
100
+ fontSize: args.fontSize,
101
+ });
102
+ if (args.out) writeFileSync(resolve(args.out), result.code);
103
+ else process.stdout.write(result.code);
104
+ // Machine-readable trailer on stderr so stdout stays a clean payload.
105
+ console.error(JSON.stringify({
106
+ fingerprint: result.fingerprint,
107
+ expectedRenderHash: result.expectedRenderHash,
108
+ fontSize: result.fontSize,
109
+ out: args.out,
110
+ }));
111
+ } catch (err) {
112
+ console.error('✗ ' + err.message);
113
+ process.exit(1);
114
+ }
115
+ }
@@ -102,7 +102,8 @@ function stagePayload(srcRoot, destRoot, skip) {
102
102
  // Scripts that never run from a consuming repo: the installer itself, the
103
103
  // reference-doc generators, and the Figma sources they inline (the doc-card
104
104
  // renderer and the Figma checks reach Figma pre-inlined inside
105
- // references/doc-card-builder.md and references/figma-checks.md). Test fixtures
105
+ // references/doc-card-builder.md, references/figma-checks.md,
106
+ // references/figma-adherence.md and references/figma-compose.md). Test fixtures
106
107
  // under lib/fixtures/ are dropped too.
107
108
  const PLUGIN_INTERNAL = new Set([
108
109
  'install.mjs',
@@ -112,6 +113,11 @@ const PLUGIN_INTERNAL = new Set([
112
113
  'build-figma-checks.mjs',
113
114
  'lib/figma-checks-read.figma.js',
114
115
  'lib/figma-checks.mjs',
116
+ 'lib/figma-variables-read.figma.js',
117
+ 'lib/figma-adherence.mjs',
118
+ 'lib/figma-adherence-read.figma.js',
119
+ 'lib/figma-compose.mjs',
120
+ 'lib/figma-compose.figma.js',
115
121
  ]);
116
122
 
117
123
  export const skipScript = (relPosix) =>
@@ -99,3 +99,40 @@ export function planDocCard(record, bodyTextStyle) {
99
99
  rows,
100
100
  };
101
101
  }
102
+
103
+ // 32-bit FNV-1a — the render hash: fnv1a(JSON.stringify(plan)). Only ever
104
+ // compared against itself (the manifest's surfaces.docCard.render), so it does
105
+ // not need to match the sha256-based canonical fingerprint, which cannot run in
106
+ // the Figma sandbox. Lives here, not in the renderer, so Node can compute the
107
+ // hash a render should return (doc-card-payload.mjs) with the same code.
108
+ export function fnv1a(str) {
109
+ let h = 0x811c9dc5;
110
+ for (let i = 0; i < str.length; i++) {
111
+ h ^= str.charCodeAt(i);
112
+ h = Math.imul(h, 0x01000193) >>> 0;
113
+ }
114
+ return h.toString(16).padStart(8, '0');
115
+ }
116
+
117
+ // When a header's title row has no node named `Status Pill`/`Status`, name the
118
+ // node that most likely IS the chip so the error can say what to rename. Never
119
+ // used to pick a node to write — the renderer still refuses to guess. A
120
+ // candidate is a FRAME below the title row whose only child is a TEXT node
121
+ // (the chip shape: a pill frame around its label). Exactly one candidate or
122
+ // no hint. Reads only type/name/id/characters/children, so it runs on Figma
123
+ // nodes and on plain test objects alike.
124
+ export function statusChipHint(titleRow) {
125
+ if (!titleRow || titleRow.type !== 'FRAME') return null;
126
+ const found = [];
127
+ const walk = (node) => {
128
+ for (const child of node.children || []) {
129
+ if (child.type === 'FRAME' && child.children && child.children.length === 1
130
+ && child.children[0].type === 'TEXT') {
131
+ found.push({ id: child.id, name: child.name, label: child.children[0].characters });
132
+ }
133
+ walk(child);
134
+ }
135
+ };
136
+ walk(titleRow);
137
+ return found.length === 1 ? found[0] : null;
138
+ }
@@ -6,20 +6,8 @@
6
6
  // BEFORE .characters, fonts loaded up front, resize() sizing modes re-asserted,
7
7
  // bound paints seeded light-gray (never pure black).
8
8
  //
9
- // In-scope globals when assembled: planDocCard, DOC_CARD_RENDERER_VERSION
10
- // (inlined planner) and the caller-filled slots RECORD, CANONICAL_FP.
11
-
12
- // 32-bit FNV-1a — the render hash. Only ever compared against itself (the
13
- // manifest's surfaces.docCard.render), so it does not need to match the
14
- // sha256-based canonical fingerprint, which cannot run in the Figma sandbox.
15
- function fnv1a(str) {
16
- let h = 0x811c9dc5;
17
- for (let i = 0; i < str.length; i++) {
18
- h ^= str.charCodeAt(i);
19
- h = Math.imul(h, 0x01000193) >>> 0;
20
- }
21
- return h.toString(16).padStart(8, '0');
22
- }
9
+ // In-scope globals when assembled: planDocCard, DOC_CARD_RENDERER_VERSION,
10
+ // fnv1a, statusChipHint (inlined planner) and the caller-filled slots RECORD, CANONICAL_FP.
23
11
 
24
12
  const REQUIRED_VARS = [
25
13
  'textDefault', 'textMuted', 'tonePositive', 'toneNegative', 'border',
@@ -151,7 +139,13 @@ async function renderDocCard({ card, record, vars, bodyTextStyle }) {
151
139
  const descAmbiguous = !named && descCandidates.length > 1;
152
140
 
153
141
  const missingAnchors = [];
154
- if (!hasStatusAnchor) missingAnchors.push('status (title row must contain a descendant named "Status Pill" or "Status")');
142
+ if (!hasStatusAnchor) {
143
+ const hint = statusChipHint(titleRow);
144
+ missingAnchors.push('status (title row must contain a descendant named "Status Pill" or "Status"'
145
+ + (hint ? ' — frame ' + hint.id + ' "' + hint.name + '" holding "' + hint.label
146
+ + '" looks like the status chip; rename it "Status Pill" and re-run' : '')
147
+ + ')');
148
+ }
155
149
  if (!headerDescCandidate) {
156
150
  missingAnchors.push(descAmbiguous
157
151
  ? 'description (found ' + descCandidates.length + ' candidate TEXT children, cannot tell which is the description — name the right one "Header Description" by hand and re-run)'