@radicool/throughline 0.25.0 → 0.27.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 (44) hide show
  1. package/README.md +8 -4
  2. package/adapters/codex/AGENTS.md +2 -0
  3. package/adapters/codex/prompts/component-builder.md +5 -3
  4. package/adapters/codex/prompts/compose-code.md +78 -0
  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/compose-code.md +78 -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/compose-code.md +78 -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 +21 -0
  19. package/references/figma-compose.md +896 -0
  20. package/scripts/README.md +8 -4
  21. package/scripts/build-doc-card-builder.mjs +16 -0
  22. package/scripts/build-figma-checks.mjs +89 -2
  23. package/scripts/compose-code.mjs +271 -0
  24. package/scripts/doc-card-payload.mjs +115 -0
  25. package/scripts/install.mjs +5 -3
  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 +21 -0
  29. package/scripts/lib/figma-compose.figma.js +326 -0
  30. package/scripts/lib/figma-compose.mjs +317 -0
  31. package/scripts/lib/fixtures/figma-compose/casing-and-suffix.json +8 -0
  32. package/scripts/lib/fixtures/figma-compose/fill-in-unlaid-slot.json +7 -0
  33. package/scripts/lib/fixtures/figma-compose/hug-on-fixed-component.json +34 -0
  34. package/scripts/lib/fixtures/figma-compose/inventory.json +295 -0
  35. package/scripts/lib/fixtures/figma-compose/many-errors.json +8 -0
  36. package/scripts/lib/fixtures/figma-compose/missing-component.json +7 -0
  37. package/scripts/lib/fixtures/figma-compose/name-taken.json +6 -0
  38. package/scripts/lib/fixtures/figma-compose/padding-sides.json +23 -0
  39. package/scripts/lib/fixtures/figma-compose/radius-kind.json +27 -0
  40. package/scripts/lib/fixtures/figma-compose/raw-number.json +5 -0
  41. package/scripts/lib/fixtures/figma-compose/sign-in-ok.json +16 -0
  42. package/scripts/lib/fixtures/figma-compose/slot-ok.json +9 -0
  43. package/scripts/lib/fixtures/figma-compose/unknown-prop-and-option.json +7 -0
  44. package/scripts/lib/fixtures/figma-compose/wrong-kind.json +5 -0
package/scripts/README.md CHANGED
@@ -25,16 +25,20 @@ 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/`. `compose-code.mjs` imports its `nearestNames`. | run from the plugin (or `.throughline/scripts`) by `compose-code.mjs`; not copied into consumer repos |
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 |
39
+ | `compose-code.mjs` | Composing a screen in code: `inventory` lists the system's components with their variant axes from `components.built` and the docs index, `check` resolves a screen's plan before any code is written and returns every gap with the nearest names, and `gate` runs the adherence gate on the one composed file plus three rules only a composed file is held to (`raw-value`, `scale-utility`, `off-plan`). Used by `/throughline:compose-code`. | run from the plugin (or `.throughline/scripts`); not copied into consumer repos |
36
40
  | `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) |
41
+ | `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
42
  | `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
43
  | `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
44
 
@@ -252,7 +256,7 @@ the committed `adapters/<target>/` tree plus the runtime payload
252
256
  (`references/` + `scripts/`, minus `scripts/adapters/`) into `.throughline/`,
253
257
  rewriting `${CLAUDE_PLUGIN_ROOT}` → `.throughline`:
254
258
 
255
- npx @radicool/throughline@0.25.0 init --target=cursor|codex|generic
259
+ npx @radicool/throughline@0.27.0 init --target=cursor|codex|generic
256
260
 
257
261
  See `scripts/install.mjs` (pure core + CLI + `install.test.mjs`).
258
262
 
@@ -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,271 @@
1
+ // Composing a screen in code: the inventory a composer may use, a check of its
2
+ // plan before any code is written, and the gate a composed file must pass. Run
3
+ // by /throughline:compose-code from the plugin (or .throughline/scripts).
4
+ //
5
+ // node scripts/compose-code.mjs inventory --system <dir> --package <spec>
6
+ // node scripts/compose-code.mjs check --system <dir> --package <spec> --plan <plan.json>
7
+ // node scripts/compose-code.mjs gate --system <dir> --package <spec> --plan <plan.json>
8
+ // --file <screen.tsx> --tokens <file> [--tokens <file>...]
9
+ //
10
+ // PLAN: { screen, file, components: [{ name, variants?: { [axis]: value } }] }
11
+ // `file` is resolved against the working directory. Sub-parts (CardHeader)
12
+ // come with their owner and need no entry. `gate` compares the file to the
13
+ // resolved plan, case-folded.
14
+ //
15
+ // Exit: 0 ok, 1 errors or failures, 2 usage or unreadable input.
16
+ //
17
+ // Spec: docs/specs/2026-10-13-compose-code-screen.md
18
+ import { readFileSync, existsSync } from 'node:fs';
19
+ import { join, resolve } from 'node:path';
20
+ import { parseArgs } from 'node:util';
21
+ import { pathToFileURL } from 'node:url';
22
+ import { normalizeName } from './lib/source-scan.mjs';
23
+ import { nearestNames } from './lib/figma-compose.mjs';
24
+ import {
25
+ extract, validate, formatReport, partOwner, blankComments,
26
+ buildTokenValues, skippedColourTokens, buildDimensionValues,
27
+ } from './validate-adherence.mjs';
28
+
29
+ export const COMPOSED_MARKER = (screen) => `// Composed by throughline: ${screen}.`;
30
+ const MARKER_LINE = /^\/\/ Composed by throughline: (.*)\.$/;
31
+ const DIRECTIVE = /^(['"])use [a-z]+\1;?$/;
32
+
33
+ // The screen a file's marker names, '' for a file with no marker. The marker is
34
+ // line 1, or line 2 after a directive ('use client').
35
+ export function readMarker(text) {
36
+ const lines = String(text).split('\n').map((l) => l.trim());
37
+ const at = DIRECTIVE.test(lines[0] ?? '') ? 1 : 0;
38
+ return lines[at]?.match(MARKER_LINE)?.[1] ?? '';
39
+ }
40
+
41
+ export function codeInventory({ manifest, index, pkg }) {
42
+ const records = new Map((index?.components ?? []).map((c) => [normalizeName(c.name), c]));
43
+ const components = (manifest?.components?.built ?? []).map((name) => {
44
+ const r = records.get(normalizeName(name));
45
+ if (!r) return { name, documented: false, variants: {}, states: [] };
46
+ return {
47
+ name,
48
+ documented: true,
49
+ summary: r.summary,
50
+ whenToUse: r.whenToUse ?? [],
51
+ whenNotToUse: r.whenNotToUse ?? [],
52
+ variants: Object.fromEntries(Object.entries(r.variants ?? {}).map(([axis, values]) => [axis, Object.keys(values)])),
53
+ states: Object.keys(r.states ?? {}),
54
+ };
55
+ });
56
+ return { package: pkg, components };
57
+ }
58
+
59
+ const fold = (s) => normalizeName(s);
60
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
61
+
62
+ // existing: null (no file at plan.file) or the marker's screen name ('' unmarked).
63
+ export function checkPlan(plan, inventory, { existing = null } = {}) {
64
+ const errors = [];
65
+ const err = (kind, path, asked, extra = {}) => errors.push({ kind, path, asked, ...extra });
66
+
67
+ if (!isObject(plan)) return { ok: false, errors: [{ kind: 'invalid', path: '', asked: plan }], plan: null };
68
+ for (const key of Object.keys(plan)) {
69
+ if (!['screen', 'file', 'components'].includes(key)) err('invalid', key, plan[key]);
70
+ }
71
+ if (typeof plan.screen !== 'string' || !plan.screen.trim()) err('invalid', 'screen', plan.screen);
72
+ if (typeof plan.file !== 'string' || !plan.file.trim()) err('invalid', 'file', plan.file);
73
+ if (!Array.isArray(plan.components)) err('invalid', 'components', plan.components);
74
+
75
+ if (existing !== null && existing !== plan.screen) err('name-taken', 'file', plan.file, { marked: existing || null });
76
+
77
+ const resolved = [];
78
+ for (const [i, c] of (Array.isArray(plan.components) ? plan.components : []).entries()) {
79
+ const path = `components[${i}]`;
80
+ if (!isObject(c) || typeof c.name !== 'string') { err('invalid', path, c); continue; }
81
+ for (const key of Object.keys(c)) if (!['name', 'variants'].includes(key)) err('invalid', `${path}.${key}`, c[key]);
82
+ const found = inventory.components.find((x) => fold(x.name) === fold(c.name));
83
+ // A sub-part (CardHeader) needs no entry, but one listed resolves to its owner.
84
+ const owner = found ? null : partOwner(c.name, inventory.components.map((x) => x.name));
85
+ if (owner) {
86
+ if (c.variants !== undefined) err('unknown-prop', `${path}.variants`, c.variants);
87
+ resolved.push({ name: c.name, partOf: owner, variants: {} });
88
+ continue;
89
+ }
90
+ if (!found) {
91
+ err('missing-component', `${path}.name`, c.name, { nearest: nearestNames(c.name, inventory.components.map((x) => x.name)) });
92
+ continue;
93
+ }
94
+ const variants = {};
95
+ if (c.variants !== undefined && !isObject(c.variants)) err('invalid', `${path}.variants`, c.variants);
96
+ for (const [axis, value] of Object.entries(isObject(c.variants) ? c.variants : {})) {
97
+ const realAxis = Object.keys(found.variants).find((a) => fold(a) === fold(axis));
98
+ if (!realAxis) { err('unknown-prop', `${path}.variants.${axis}`, axis); continue; }
99
+ const realValue = found.variants[realAxis].find((v) => fold(v) === fold(value));
100
+ if (!realValue) { err('unknown-option', `${path}.variants.${axis}`, value, { options: found.variants[realAxis] }); continue; }
101
+ variants[realAxis] = realValue;
102
+ }
103
+ resolved.push({ name: found.name, variants });
104
+ }
105
+
106
+ const ok = errors.length === 0;
107
+ return { ok, errors, plan: ok ? { screen: plan.screen, file: plan.file, components: resolved } : null };
108
+ }
109
+
110
+ // Default-scale Tailwind spacing, sizing, type-size and weight utilities, after
111
+ // any variants (`md:`) and as negatives. Throughline's generated preset extends
112
+ // only colours, radius and font family, so these are Tailwind's own values, not
113
+ // tokens. Read anywhere in the file, so cn() and template strings count. Passes:
114
+ // 0, fractions (`w-1/2`), keywords (`full`, `auto`, `screen`), preset names
115
+ // (`text-primary`, `font-sans`) and `[var(--…)]`.
116
+ const SCALE_UTILITY =
117
+ /(?<![\w-])(?:[\w-]+:)*-?(?:(?:p[xytrblse]?|m[xytrblse]?|gap(?:-[xy])?|space-[xy]|w|h|size|min-w|min-h|max-w|max-h|inset(?:-[xy])?|top|right|bottom|left|text|leading|tracking)-(?:0\.\d+|[1-9]\d*(?:\.\d+)?|px|xs|sm|base|lg|xl|[2-9]xl)|font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black))(?![\w/-])/g;
118
+ // Sizing the gate doesn't read: an arbitrary size that isn't a variable, and a
119
+ // width or height declaration with a raw length (percentages are layout).
120
+ const RAW_SIZE = [
121
+ /(?<![\w-])(?:[\w-]+:)*(?:w|h|size|min-w|min-h|max-w|max-h)-\[(?!var\()(?!\d+(?:\.\d+)?(?:%|[dsl]?v[hw])\])[^\]\s]+\]/g,
122
+ /(?<![\w-])(?:min-?|max-?)?(?:[wW]idth|[hH]eight)\s*:\s*['"]?-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)?(?![\w%.])/g,
123
+ ];
124
+ const NATIVE_CONTROL = /<(button|input|select|textarea)\b/g;
125
+ // Every module reference: a static import (named, default, namespace,
126
+ // type-only, side-effect), a re-export, a dynamic import() and a require().
127
+ const ANY_IMPORT = [
128
+ /^\s*(?:import|export)\s+(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/gm,
129
+ /(?<![\w$.])(?:import|require)\s*\(\s*['"]([^'"]+)['"]/g,
130
+ ];
131
+ const REACT = /^react(?:-dom)?(?:\/.*)?$/;
132
+
133
+ const lineOf = (text, i) => text.slice(0, i).split('\n').length;
134
+
135
+ export function gateFile({ file, text, plan, manifest, index, tokenDicts, pkg }) {
136
+ const marked = readMarker(text);
137
+ const extracted = extract(text, pkg, file);
138
+ const r = validate({
139
+ built: manifest?.components?.built ?? [],
140
+ index,
141
+ tokenValues: buildTokenValues(tokenDicts),
142
+ colourSkipped: skippedColourTokens(tokenDicts),
143
+ dimensionValues: buildDimensionValues(tokenDicts),
144
+ files: [{ path: file, ...extracted }],
145
+ walked: 1,
146
+ });
147
+
148
+ // On one file, known components that set no variant prop are normal; the plan
149
+ // check already validated every variant value against the same index.
150
+ const failures = r.failures.filter((f) => f.rule !== 'variant-rule-inert');
151
+ const advisories = [...r.advisories, ...r.failures.filter((f) => f.rule === 'variant-rule-inert')];
152
+
153
+ // Without the marker, the next run treats this file as a person's.
154
+ if (marked !== plan?.screen) failures.push({ rule: 'marker', written: marked || '(none)', expected: plan?.screen, file, line: 1 });
155
+
156
+ for (const l of extracted.literals) failures.push({ rule: 'raw-value', written: l.value, file, line: l.line });
157
+ for (const d of extracted.dimensions) failures.push({ rule: 'raw-value', written: d.written, file, line: d.line });
158
+
159
+ const code = blankComments(text, file);
160
+ for (const re of RAW_SIZE) for (const m of code.matchAll(re)) failures.push({ rule: 'raw-value', written: m[0], file, line: lineOf(code, m.index) });
161
+ for (const m of code.matchAll(SCALE_UTILITY)) failures.push({ rule: 'scale-utility', written: m[0], file, line: lineOf(code, m.index) });
162
+ for (const re of ANY_IMPORT) for (const m of code.matchAll(re)) {
163
+ const from = m[1];
164
+ if (from === pkg || from.startsWith(`${pkg}/`) || REACT.test(from)) continue;
165
+ failures.push({ rule: 'off-plan', written: `import from '${from}'`, file, line: lineOf(code, m.index + m[0].search(/\S/)) });
166
+ }
167
+ for (const m of code.matchAll(NATIVE_CONTROL)) failures.push({ rule: 'off-plan', written: `<${m[1]}>`, file, line: lineOf(code, m.index) });
168
+
169
+ const planned = (plan?.components ?? []).map((c) => c.partOf ?? c.name);
170
+ const plannedKeys = new Set(planned.map(fold));
171
+ for (const e of extracted.elements) {
172
+ if (plannedKeys.has(fold(e.component)) || partOwner(e.component, planned)) continue;
173
+ failures.push({ rule: 'off-plan', written: `<${e.component}>`, file, line: e.line });
174
+ }
175
+
176
+ return { ...r, ok: failures.length === 0, failures, advisories };
177
+ }
178
+
179
+ const COMPOSER_RULES = {
180
+ marker: (f) => `${f.file} is marked for ${f.written}, not "${f.expected}" — its first line (second after a directive) must be ${COMPOSED_MARKER(f.expected)}`,
181
+ 'raw-value': (f) => `${f.written} at ${f.file}:${f.line} — write a token variable, not a raw value`,
182
+ 'scale-utility': (f) => `${f.written} at ${f.file}:${f.line} — Tailwind's default scale, not a token; use a token variable (\`-[var(--…)]\`)`,
183
+ 'off-plan': (f) => f.written.startsWith('import')
184
+ ? `${f.written} at ${f.file}:${f.line} — a composed screen imports only from the system's package and React; anything else is a gap to report`
185
+ : `${f.written} at ${f.file}:${f.line} — not a planned system component; use one from the plan, or check the plan again`,
186
+ };
187
+
188
+ export function formatGateReport(r) {
189
+ const own = r.failures.filter((f) => COMPOSER_RULES[f.rule]);
190
+ const lines = formatReport({ ...r, failures: r.failures.filter((f) => !COMPOSER_RULES[f.rule]) });
191
+ if (own.length) {
192
+ lines.push(`\n${own.length} composer failure(s):`);
193
+ for (const f of own) lines.push(` - [${f.rule}] ${COMPOSER_RULES[f.rule](f)}`);
194
+ }
195
+ lines.push(r.ok ? '\ncompose-code gate: pass' : '\ncompose-code gate: FAIL');
196
+ return lines;
197
+ }
198
+
199
+ function main() {
200
+ const [mode, ...rest] = process.argv.slice(2);
201
+ let values;
202
+ try {
203
+ ({ values } = parseArgs({
204
+ args: rest,
205
+ options: {
206
+ system: { type: 'string' },
207
+ package: { type: 'string' },
208
+ tokens: { type: 'string', multiple: true, default: [] },
209
+ plan: { type: 'string' },
210
+ file: { type: 'string' },
211
+ },
212
+ }));
213
+ } catch (e) {
214
+ console.error(e.message);
215
+ process.exit(2);
216
+ }
217
+ const usage = () => {
218
+ console.error('usage: compose-code.mjs inventory|check|gate --system <dir> --package <spec> [--plan <file>] [--tokens <file> --tokens <file>...] [--file <path>]');
219
+ process.exit(2);
220
+ };
221
+ if (!['inventory', 'check', 'gate'].includes(mode) || !values.system || !values.package) usage();
222
+ if (mode !== 'inventory' && !values.plan) usage();
223
+ if (mode === 'gate' && (!values.file || values.tokens.length === 0)) usage();
224
+
225
+ const read = (p, what, parse = true) => {
226
+ try {
227
+ const text = readFileSync(p, 'utf8');
228
+ return parse ? JSON.parse(text) : text;
229
+ } catch (e) {
230
+ console.error(`cannot read ${what} at ${p}: ${e.message}`);
231
+ process.exit(2);
232
+ }
233
+ };
234
+ const manifest = read(join(values.system, 'design-system.json'), 'the manifest');
235
+ const index = read(join(values.system, 'design-system/docs/index.json'), 'the docs index (run docs:digest first)');
236
+ const inventory = codeInventory({ manifest, index, pkg: values.package });
237
+
238
+ if (mode === 'inventory') {
239
+ console.log(JSON.stringify(inventory, null, 2));
240
+ return;
241
+ }
242
+
243
+ const plan = read(values.plan, 'the plan');
244
+ if (mode === 'check') {
245
+ const target = typeof plan?.file === 'string' ? resolve(plan.file) : null;
246
+ const existing = target && existsSync(target) ? readMarker(read(target, 'the target file', false)) : null;
247
+ const res = checkPlan(plan, inventory, { existing });
248
+ console.log(JSON.stringify(res, null, 2));
249
+ process.exit(res.ok ? 0 : 1);
250
+ }
251
+
252
+ // The gate holds a file to a plan check accepts, never to a rejected one.
253
+ const checked = checkPlan(plan, inventory);
254
+ if (!checked.ok) {
255
+ console.error(`the plan doesn't check: ${checked.errors.map((e) => `${e.kind} at ${e.path}`).join(', ')}. Run check and fix it first.`);
256
+ process.exit(2);
257
+ }
258
+ const r = gateFile({
259
+ file: values.file,
260
+ text: read(values.file, 'the screen file', false),
261
+ plan: checked.plan,
262
+ manifest,
263
+ index,
264
+ tokenDicts: values.tokens.map((f) => read(f, 'a token source')),
265
+ pkg: values.package,
266
+ });
267
+ for (const line of formatGateReport(r)) console.log(line);
268
+ process.exit(r.ok ? 0 : 1);
269
+ }
270
+
271
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
@@ -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,9 +102,10 @@ 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
107
- // under lib/fixtures/ are dropped too.
105
+ // references/doc-card-builder.md, references/figma-checks.md,
106
+ // references/figma-adherence.md and references/figma-compose.md). Test fixtures
107
+ // under lib/fixtures/ are dropped too. lib/figma-compose.mjs is inlined the same
108
+ // way but stays: compose-code.mjs imports nearestNames from it.
108
109
  const PLUGIN_INTERNAL = new Set([
109
110
  'install.mjs',
110
111
  'build-doc-card-builder.mjs',
@@ -116,6 +117,7 @@ const PLUGIN_INTERNAL = new Set([
116
117
  'lib/figma-variables-read.figma.js',
117
118
  'lib/figma-adherence.mjs',
118
119
  'lib/figma-adherence-read.figma.js',
120
+ 'lib/figma-compose.figma.js',
119
121
  ]);
120
122
 
121
123
  export const skipScript = (relPosix) =>