@radicool/throughline 0.25.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 (40) hide show
  1. package/README.md +6 -4
  2. package/adapters/codex/AGENTS.md +1 -0
  3. package/adapters/codex/prompts/component-builder.md +5 -3
  4. package/adapters/codex/prompts/compose-figma.md +59 -0
  5. package/adapters/codex/prompts/document-component.md +3 -2
  6. package/adapters/cursor/.cursor/commands/compose-figma.md +59 -0
  7. package/adapters/cursor/.cursor/commands/document-component.md +3 -2
  8. package/adapters/cursor/.cursor/rules/component-builder.mdc +5 -3
  9. package/adapters/generic/AGENTS.md +1 -0
  10. package/adapters/generic/commands/compose-figma.md +59 -0
  11. package/adapters/generic/commands/document-component.md +3 -2
  12. package/adapters/generic/skills/component-builder/SKILL.md +5 -3
  13. package/package.json +1 -1
  14. package/references/doc-card-builder.md +62 -15
  15. package/references/figma-adherence.md +21 -0
  16. package/references/figma-compose.md +896 -0
  17. package/scripts/README.md +7 -4
  18. package/scripts/build-doc-card-builder.mjs +16 -0
  19. package/scripts/build-figma-checks.mjs +89 -2
  20. package/scripts/doc-card-payload.mjs +115 -0
  21. package/scripts/install.mjs +4 -2
  22. package/scripts/lib/doc-card-plan.mjs +37 -0
  23. package/scripts/lib/doc-card-render.figma.js +9 -15
  24. package/scripts/lib/figma-adherence-read.figma.js +21 -0
  25. package/scripts/lib/figma-compose.figma.js +326 -0
  26. package/scripts/lib/figma-compose.mjs +317 -0
  27. package/scripts/lib/fixtures/figma-compose/casing-and-suffix.json +8 -0
  28. package/scripts/lib/fixtures/figma-compose/fill-in-unlaid-slot.json +7 -0
  29. package/scripts/lib/fixtures/figma-compose/hug-on-fixed-component.json +34 -0
  30. package/scripts/lib/fixtures/figma-compose/inventory.json +295 -0
  31. package/scripts/lib/fixtures/figma-compose/many-errors.json +8 -0
  32. package/scripts/lib/fixtures/figma-compose/missing-component.json +7 -0
  33. package/scripts/lib/fixtures/figma-compose/name-taken.json +6 -0
  34. package/scripts/lib/fixtures/figma-compose/padding-sides.json +23 -0
  35. package/scripts/lib/fixtures/figma-compose/radius-kind.json +27 -0
  36. package/scripts/lib/fixtures/figma-compose/raw-number.json +5 -0
  37. package/scripts/lib/fixtures/figma-compose/sign-in-ok.json +16 -0
  38. package/scripts/lib/fixtures/figma-compose/slot-ok.json +9 -0
  39. package/scripts/lib/fixtures/figma-compose/unknown-prop-and-option.json +7 -0
  40. package/scripts/lib/fixtures/figma-compose/wrong-kind.json +5 -0
package/scripts/README.md CHANGED
@@ -25,16 +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 both `references/figma-checks.md` and `references/figma-adherence.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
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. Inlined into `references/figma-adherence.md`; never executed as a module. | 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) |
31
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 |
32
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` |
33
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` |
34
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` |
35
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 |
36
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) |
37
- | `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`); and `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`. `--check` gates CI on both. | 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) |
38
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) |
39
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) |
40
43
 
@@ -252,7 +255,7 @@ the committed `adapters/<target>/` tree plus the runtime payload
252
255
  (`references/` + `scripts/`, minus `scripts/adapters/`) into `.throughline/`,
253
256
  rewriting `${CLAUDE_PLUGIN_ROOT}` → `.throughline`:
254
257
 
255
- npx @radicool/throughline@0.25.0 init --target=cursor|codex|generic
258
+ npx @radicool/throughline@0.26.0 init --target=cursor|codex|generic
256
259
 
257
260
  See `scripts/install.mjs` (pure core + CLI + `install.test.mjs`).
258
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
  '',
@@ -8,8 +8,11 @@
8
8
  // references/figma-adherence.md — the screen adherence gate: the pure checker
9
9
  // (lib/figma-adherence.mjs), then lib/figma-variables-read.figma.js and the
10
10
  // screen reader (lib/figma-adherence-read.figma.js).
11
- // Mirrors build-doc-card-builder.mjs: run bare to write both, run with --check to
12
- // gate CI on both. Zero dependencies.
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.
13
16
  import { readFileSync, writeFileSync } from 'node:fs';
14
17
  import { join, dirname } from 'node:path';
15
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -22,8 +25,11 @@ const READER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks-read.figma.js');
22
25
  const VARIABLES = join(REPO_ROOT, 'scripts', 'lib', 'figma-variables-read.figma.js');
23
26
  const ADHERENCE = join(REPO_ROOT, 'scripts', 'lib', 'figma-adherence.mjs');
24
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');
25
30
  const OUT = join(REPO_ROOT, 'references', 'figma-checks.md');
26
31
  const ADHERENCE_OUT = join(REPO_ROOT, 'references', 'figma-adherence.md');
32
+ const COMPOSE_OUT = join(REPO_ROOT, 'references', 'figma-compose.md');
27
33
 
28
34
  const BIND = 'const { checkSpec, checkBuilt, checkExisting } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });';
29
35
 
@@ -143,6 +149,73 @@ const ADHERENCE_HEADER = [
143
149
  '```js',
144
150
  ].join('\n');
145
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
+
146
219
  const FOOTER = ['```', ''].join('\n');
147
220
 
148
221
  const stripExports = (src) =>
@@ -170,6 +243,13 @@ export function buildFigmaAdherence({ checkerSource, variablesSource, readerSour
170
243
  return `${ADHERENCE_HEADER}\n${checker}\n${variablesSource}\n${readerSource}${FOOTER}`;
171
244
  }
172
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}`;
251
+ }
252
+
173
253
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
174
254
  const result = buildFigmaChecks({
175
255
  statesSource: readFileSync(STATES, 'utf8'),
@@ -183,9 +263,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
183
263
  variablesSource: readFileSync(VARIABLES, 'utf8'),
184
264
  readerSource: readFileSync(ADHERENCE_READER, 'utf8'),
185
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
+ });
186
272
  const outputs = [
187
273
  [OUT, result, 'references/figma-checks.md', 'figma checks'],
188
274
  [ADHERENCE_OUT, adherence, 'references/figma-adherence.md', 'figma adherence'],
275
+ [COMPOSE_OUT, compose, 'references/figma-compose.md', 'figma compose'],
189
276
  ];
190
277
  if (process.argv.includes('--check')) {
191
278
  let stale = false;
@@ -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,8 +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, references/figma-checks.md and
106
- // references/figma-adherence.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
107
107
  // under lib/fixtures/ are dropped too.
108
108
  const PLUGIN_INTERNAL = new Set([
109
109
  'install.mjs',
@@ -116,6 +116,8 @@ const PLUGIN_INTERNAL = new Set([
116
116
  'lib/figma-variables-read.figma.js',
117
117
  'lib/figma-adherence.mjs',
118
118
  'lib/figma-adherence-read.figma.js',
119
+ 'lib/figma-compose.mjs',
120
+ 'lib/figma-compose.figma.js',
119
121
  ]);
120
122
 
121
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)'
@@ -111,6 +111,19 @@ function overriddenRecords(instance, path, underDetached) {
111
111
  return records;
112
112
  }
113
113
 
114
+ // The SLOT nodes on this instance's own surface: its slots, and the slots of
115
+ // instances nested in its component, reached only through component sublayers
116
+ // (ids 'I…'). A slot inside content added to a slot is left out here; the walk
117
+ // reaches it through that content.
118
+ function slotsOf(instance) {
119
+ return instance.findAll((n) => n.type === 'SLOT').filter((slot) => {
120
+ for (let node = slot.parent; node && node !== instance; node = node.parent) {
121
+ if (!String(node.id).startsWith('I')) return false;
122
+ }
123
+ return true;
124
+ });
125
+ }
126
+
114
127
  function screensOn(page, names) {
115
128
  const all = [];
116
129
  for (const top of page.children) {
@@ -171,6 +184,14 @@ async function readScreens(target) {
171
184
  if (node.visible === false) return;
172
185
  if (node.type === 'INSTANCE') {
173
186
  nodes.push(...overriddenRecords(node, path, insideDetached));
187
+ // Content added to a slot is the screen's own, and isn't listed in
188
+ // instance.overrides. A slot's placeholder children keep instance-sublayer
189
+ // ids ('I…;…') and stay part of the component.
190
+ for (const slot of slotsOf(node)) {
191
+ for (const child of slot.children) {
192
+ if (!String(child.id).startsWith('I')) walk(child, `${path} / ${slot.name} / ${child.name}`, false, insideDetached);
193
+ }
194
+ }
174
195
  return;
175
196
  }
176
197
  const mixedPaints = node.fills === figma.mixed;