@radicool/throughline 0.26.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.
package/README.md CHANGED
@@ -73,7 +73,7 @@ Everything above was created during a single working session and synced directly
73
73
 
74
74
  | | |
75
75
  |---|---|
76
- | **A supported agent** | Required — [Claude Code](https://docs.claude.com/en/docs/claude-code) (native plugin), or **Cursor**, **Codex**, or any **AGENTS.md**-aware agent via `npx @radicool/throughline@0.26.0 init`. |
76
+ | **A supported agent** | Required — [Claude Code](https://docs.claude.com/en/docs/claude-code) (native plugin), or **Cursor**, **Codex**, or any **AGENTS.md**-aware agent via `npx @radicool/throughline@0.27.0 init`. |
77
77
  | **Figma** | Required, **desktop app** (the browser version causes connection errors). **Professional plan or higher recommended** — multi-mode variables (Light/Dark, brand themes) need it. |
78
78
  | **Figma access token** | Optional — only for the few Figma calls the Desktop Bridge can't make, like reading a published library. Without it, you work through the bridge alone. The setup skill walks you through it; your token stays yours and is never shared in chat. |
79
79
  | **GitHub** (or similar) | Optional — only when you're ready to graduate to a real remote repo with PRs and CI. |
@@ -111,6 +111,8 @@ Already have screens or components in Figma? `/throughline:check-figma` checks t
111
111
 
112
112
  Need a new screen? `/throughline:compose-figma` builds it in Figma from your own components and tokens, every value bound. If the screen needs something your system doesn't have, it says so instead of drawing a lookalike, and it runs the same check before handing the screen over.
113
113
 
114
+ Want it in code instead? `/throughline:compose-code` writes the screen as one React file in your app, using only your components and your tokens. It checks the plan before writing a line, reports any component you don't have rather than building one, and gates the file before handing it over.
115
+
114
116
  Update anytime with `/plugin marketplace update throughline-marketplace`.
115
117
 
116
118
  #### Cursor, Codex, or a generic AGENTS.md agent
@@ -118,9 +120,9 @@ Update anytime with `/plugin marketplace update throughline-marketplace`.
118
120
  Run the installer in your project — it stamps in the skills, the reference docs, the scripts, and the Figma MCP config for your tool:
119
121
 
120
122
  ```
121
- npx @radicool/throughline@0.26.0 init --target=cursor # → .cursor/rules + .cursor/mcp.json
122
- npx @radicool/throughline@0.26.0 init --target=codex # → prompts/ + AGENTS.md index + codex-mcp.toml
123
- npx @radicool/throughline@0.26.0 init --target=generic # → skills/ + AGENTS.md index
123
+ npx @radicool/throughline@0.27.0 init --target=cursor # → .cursor/rules + .cursor/mcp.json
124
+ npx @radicool/throughline@0.27.0 init --target=codex # → prompts/ + AGENTS.md index + codex-mcp.toml
125
+ npx @radicool/throughline@0.27.0 init --target=generic # → skills/ + AGENTS.md index
124
126
  ```
125
127
 
126
128
  It's safe to re-run (it merges `AGENTS.md` and `.cursor/mcp.json` non-destructively) and stages everything the skills read into `.throughline/`. Then open the `figma-environment-setup` skill/rule/prompt for your tool to begin. For Codex, add the printed `codex-mcp.toml` block to your Codex config to enable Figma access.
@@ -20,6 +20,7 @@ ThroughLine builds a design system end to end. Load the matching prompt for the
20
20
  ## ThroughLine commands
21
21
 
22
22
  - `check-figma` — Check what's already in Figma against the design system, read-only — screens for detached components and raw values a token has, and existing component sets for their states, focus ring and focus contrast. → load `prompts/check-figma.md`.
23
+ - `compose-code` — Compose a screen in code from the design system's own components and tokens — plan checked before writing, every value a token, gated before it's handed over. → load `prompts/compose-code.md`.
23
24
  - `compose-figma` — Compose a screen in Figma from the design system's own components, variables and text styles — every value bound, nothing invented, gated before it's handed over. → load `prompts/compose-figma.md`.
24
25
  - `design-system-status` — Show a plain-language summary of the current design system state — what's set up, what's not, and sensible next steps — read from design-system.json. → load `prompts/design-system-status.md`.
25
26
  - `document-component` — Author, refresh, or reconcile the usage documentation for one existing component — draft its canonical doc record from four sources, project it to Figma, the doc card, and code, and resolve any drift via a reviewable per-item choice. → load `prompts/document-component.md`.
@@ -0,0 +1,78 @@
1
+ Compose the screen the user asked for as one React file in their app, from what
2
+ the design system already has. Use the settings already in `design-system.json`
3
+ rather than re-asking them. Scale explanation to `user.codingLevel`.
4
+
5
+ 1. **Know the framework.** Read `project.uiFramework` from `design-system.json`.
6
+ Composing in code covers `shadcn` and `tailwind`. For anything else, stop and
7
+ say composing in code doesn't cover that framework yet.
8
+ 2. **Find the gate settings.** Search the repo's `package.json` files (not under
9
+ `node_modules`) for an `adherence:check` script. If one exists, take its
10
+ `--system`, `--package` and every `--tokens`, each resolved against the
11
+ directory of the `package.json` that holds it. Otherwise ask the user for the
12
+ package specifier the app imports the system from and the token source files,
13
+ and use the repo root as `--system`. Pass every token file: a system whose
14
+ values span modes has one per mode.
15
+ 3. **Know the target.** Unless the user named it, ask for the screen's name and
16
+ the file path to write it to, inside the app. Write `.tsx`, or `.jsx` when the
17
+ app has no TypeScript.
18
+ 4. **Read what exists.** Run
19
+ `node .throughline/scripts/compose-code.mjs inventory --system <dir> --package <spec>`.
20
+ Its components, with their variant axes, values, states, `whenToUse` and
21
+ `whenNotToUse`, are the only components the screen may use. Read a listed
22
+ component's own source for its prop names and exported sub-parts
23
+ (`CardHeader`); the inventory records neither. Find it by resolving the
24
+ package specifier: a workspace package in a monorepo, or the app's
25
+ `node_modules` otherwise. Token variable names come from
26
+ the system's generated CSS (the file its styles import), and the utilities
27
+ from its Tailwind preset; read both.
28
+ 5. **Plan.** Write the plan as JSON to a scratch file:
29
+ `{ "screen", "file", "components": [{ "name", "variants": { "<axis>": "<value>" } }] }`,
30
+ one entry per component the screen uses, with the variant values it sets. A
31
+ sub-part (`CardHeader`) comes with its owner (`Card`) and needs no entry. When
32
+ the request needs a component the inventory doesn't have, say so before
33
+ writing and ask: leave it out, use a named existing component instead, or stop.
34
+ The `nearest` names are matched on spelling; suggest the alternative that fits
35
+ by `whenToUse` instead when one does. Never build a lookalike. An icon, or
36
+ anything else the system's package doesn't export, is a gap in the same way:
37
+ the screen imports only from the package and React.
38
+ 6. **Check the plan.** Run the script with `check --system <dir> --package <spec> --plan <plan file>`
39
+ from the repo root. If it returns errors, nothing has been written: fix the
40
+ plan (a `missing-component` error goes back to the user as in step 5) and check
41
+ again. A `name-taken` error means the file at that path wasn't composed for
42
+ this screen: ask for another path, or for the user to move that file. A
43
+ re-run replaces an earlier composed file only when the screen name matches
44
+ its marker exactly, so reuse the name as written. Write
45
+ the variant values in the casing the returned plan uses.
46
+ 7. **Write.** Put the marker `// Composed by throughline: <screen>.` on the first
47
+ line, or on the second after a `'use client'` directive. Import components only
48
+ from the package specifier. Plain elements carry layout only (flex, grid, gap,
49
+ padding, sizing); visible text uses semantic elements (`h1`, `p`, `label`);
50
+ everything the user operates is a system component from the plan (a link may
51
+ be a plain `<a>`). Write colour, spacing, sizing, type size and font weight
52
+ only as token variables
53
+ (`gap-[var(--space-gap-md)]`, `style={{ padding: 'var(--space-4)' }}`) or the
54
+ utilities the system's preset generates from tokens (`bg-primary`,
55
+ `rounded-md`). Never a raw value, and never Tailwind's default scale
56
+ (`gap-4`, `text-lg`, `font-semibold`). Percentages, viewport units,
57
+ fractions (`w-1/2`) and keywords (`w-full`) are layout and fine. The gate
58
+ reads class names anywhere in the file, so visible copy that spells one
59
+ (`text-sm`) fails too; reword the copy rather than the rule. The gate can't
60
+ see a lookalike built from plain elements (a `div` with `role="switch"`), so
61
+ that one is yours to refuse. A size no token holds (a 400px card) can't be bound:
62
+ use a fraction or a keyword and say so in the report. Handlers are stubs; data and state are the app's.
63
+ 8. **Gate.** Run the script with
64
+ `gate --system <dir> --package <spec> --plan <plan file> --file <screen file>`,
65
+ with `--tokens <file>` repeated once per token file.
66
+ On a failure, fix the file and run it again, up to three attempts in all. If
67
+ the third still fails, stop: the file stays where it is, marked, and the
68
+ report lists every failure and says a re-run replaces it. Never change the
69
+ plan or skip a rule to make it pass.
70
+ 9. **Report.** The file's path, whether it replaced an earlier composed one, the
71
+ gate's counts, every gap (each component asked for that doesn't exist, with the
72
+ nearest names the script offered, and what was left out because of it), and
73
+ how to mount the screen where the app picks its screens (its router or entry).
74
+ Don't edit it.
75
+
76
+ Never edit a component, a token or the app's routing. If the screen can't be
77
+ built from what the system has, the gap is the finding.
78
+
@@ -0,0 +1,78 @@
1
+ Compose the screen the user asked for as one React file in their app, from what
2
+ the design system already has. Use the settings already in `design-system.json`
3
+ rather than re-asking them. Scale explanation to `user.codingLevel`.
4
+
5
+ 1. **Know the framework.** Read `project.uiFramework` from `design-system.json`.
6
+ Composing in code covers `shadcn` and `tailwind`. For anything else, stop and
7
+ say composing in code doesn't cover that framework yet.
8
+ 2. **Find the gate settings.** Search the repo's `package.json` files (not under
9
+ `node_modules`) for an `adherence:check` script. If one exists, take its
10
+ `--system`, `--package` and every `--tokens`, each resolved against the
11
+ directory of the `package.json` that holds it. Otherwise ask the user for the
12
+ package specifier the app imports the system from and the token source files,
13
+ and use the repo root as `--system`. Pass every token file: a system whose
14
+ values span modes has one per mode.
15
+ 3. **Know the target.** Unless the user named it, ask for the screen's name and
16
+ the file path to write it to, inside the app. Write `.tsx`, or `.jsx` when the
17
+ app has no TypeScript.
18
+ 4. **Read what exists.** Run
19
+ `node .throughline/scripts/compose-code.mjs inventory --system <dir> --package <spec>`.
20
+ Its components, with their variant axes, values, states, `whenToUse` and
21
+ `whenNotToUse`, are the only components the screen may use. Read a listed
22
+ component's own source for its prop names and exported sub-parts
23
+ (`CardHeader`); the inventory records neither. Find it by resolving the
24
+ package specifier: a workspace package in a monorepo, or the app's
25
+ `node_modules` otherwise. Token variable names come from
26
+ the system's generated CSS (the file its styles import), and the utilities
27
+ from its Tailwind preset; read both.
28
+ 5. **Plan.** Write the plan as JSON to a scratch file:
29
+ `{ "screen", "file", "components": [{ "name", "variants": { "<axis>": "<value>" } }] }`,
30
+ one entry per component the screen uses, with the variant values it sets. A
31
+ sub-part (`CardHeader`) comes with its owner (`Card`) and needs no entry. When
32
+ the request needs a component the inventory doesn't have, say so before
33
+ writing and ask: leave it out, use a named existing component instead, or stop.
34
+ The `nearest` names are matched on spelling; suggest the alternative that fits
35
+ by `whenToUse` instead when one does. Never build a lookalike. An icon, or
36
+ anything else the system's package doesn't export, is a gap in the same way:
37
+ the screen imports only from the package and React.
38
+ 6. **Check the plan.** Run the script with `check --system <dir> --package <spec> --plan <plan file>`
39
+ from the repo root. If it returns errors, nothing has been written: fix the
40
+ plan (a `missing-component` error goes back to the user as in step 5) and check
41
+ again. A `name-taken` error means the file at that path wasn't composed for
42
+ this screen: ask for another path, or for the user to move that file. A
43
+ re-run replaces an earlier composed file only when the screen name matches
44
+ its marker exactly, so reuse the name as written. Write
45
+ the variant values in the casing the returned plan uses.
46
+ 7. **Write.** Put the marker `// Composed by throughline: <screen>.` on the first
47
+ line, or on the second after a `'use client'` directive. Import components only
48
+ from the package specifier. Plain elements carry layout only (flex, grid, gap,
49
+ padding, sizing); visible text uses semantic elements (`h1`, `p`, `label`);
50
+ everything the user operates is a system component from the plan (a link may
51
+ be a plain `<a>`). Write colour, spacing, sizing, type size and font weight
52
+ only as token variables
53
+ (`gap-[var(--space-gap-md)]`, `style={{ padding: 'var(--space-4)' }}`) or the
54
+ utilities the system's preset generates from tokens (`bg-primary`,
55
+ `rounded-md`). Never a raw value, and never Tailwind's default scale
56
+ (`gap-4`, `text-lg`, `font-semibold`). Percentages, viewport units,
57
+ fractions (`w-1/2`) and keywords (`w-full`) are layout and fine. The gate
58
+ reads class names anywhere in the file, so visible copy that spells one
59
+ (`text-sm`) fails too; reword the copy rather than the rule. The gate can't
60
+ see a lookalike built from plain elements (a `div` with `role="switch"`), so
61
+ that one is yours to refuse. A size no token holds (a 400px card) can't be bound:
62
+ use a fraction or a keyword and say so in the report. Handlers are stubs; data and state are the app's.
63
+ 8. **Gate.** Run the script with
64
+ `gate --system <dir> --package <spec> --plan <plan file> --file <screen file>`,
65
+ with `--tokens <file>` repeated once per token file.
66
+ On a failure, fix the file and run it again, up to three attempts in all. If
67
+ the third still fails, stop: the file stays where it is, marked, and the
68
+ report lists every failure and says a re-run replaces it. Never change the
69
+ plan or skip a rule to make it pass.
70
+ 9. **Report.** The file's path, whether it replaced an earlier composed one, the
71
+ gate's counts, every gap (each component asked for that doesn't exist, with the
72
+ nearest names the script offered, and what was left out because of it), and
73
+ how to mount the screen where the app picks its screens (its router or entry).
74
+ Don't edit it.
75
+
76
+ Never edit a component, a token or the app's routing. If the screen can't be
77
+ built from what the system has, the gap is the finding.
78
+
@@ -20,6 +20,7 @@ ThroughLine builds a design system end to end. Read the matching skill file for
20
20
  ## ThroughLine commands
21
21
 
22
22
  - `check-figma` — Check what's already in Figma against the design system, read-only — screens for detached components and raw values a token has, and existing component sets for their states, focus ring and focus contrast. → read `commands/check-figma.md`.
23
+ - `compose-code` — Compose a screen in code from the design system's own components and tokens — plan checked before writing, every value a token, gated before it's handed over. → read `commands/compose-code.md`.
23
24
  - `compose-figma` — Compose a screen in Figma from the design system's own components, variables and text styles — every value bound, nothing invented, gated before it's handed over. → read `commands/compose-figma.md`.
24
25
  - `design-system-status` — Show a plain-language summary of the current design system state — what's set up, what's not, and sensible next steps — read from design-system.json. → read `commands/design-system-status.md`.
25
26
  - `document-component` — Author, refresh, or reconcile the usage documentation for one existing component — draft its canonical doc record from four sources, project it to Figma, the doc card, and code, and resolve any drift via a reviewable per-item choice. → read `commands/document-component.md`.
@@ -0,0 +1,78 @@
1
+ Compose the screen the user asked for as one React file in their app, from what
2
+ the design system already has. Use the settings already in `design-system.json`
3
+ rather than re-asking them. Scale explanation to `user.codingLevel`.
4
+
5
+ 1. **Know the framework.** Read `project.uiFramework` from `design-system.json`.
6
+ Composing in code covers `shadcn` and `tailwind`. For anything else, stop and
7
+ say composing in code doesn't cover that framework yet.
8
+ 2. **Find the gate settings.** Search the repo's `package.json` files (not under
9
+ `node_modules`) for an `adherence:check` script. If one exists, take its
10
+ `--system`, `--package` and every `--tokens`, each resolved against the
11
+ directory of the `package.json` that holds it. Otherwise ask the user for the
12
+ package specifier the app imports the system from and the token source files,
13
+ and use the repo root as `--system`. Pass every token file: a system whose
14
+ values span modes has one per mode.
15
+ 3. **Know the target.** Unless the user named it, ask for the screen's name and
16
+ the file path to write it to, inside the app. Write `.tsx`, or `.jsx` when the
17
+ app has no TypeScript.
18
+ 4. **Read what exists.** Run
19
+ `node .throughline/scripts/compose-code.mjs inventory --system <dir> --package <spec>`.
20
+ Its components, with their variant axes, values, states, `whenToUse` and
21
+ `whenNotToUse`, are the only components the screen may use. Read a listed
22
+ component's own source for its prop names and exported sub-parts
23
+ (`CardHeader`); the inventory records neither. Find it by resolving the
24
+ package specifier: a workspace package in a monorepo, or the app's
25
+ `node_modules` otherwise. Token variable names come from
26
+ the system's generated CSS (the file its styles import), and the utilities
27
+ from its Tailwind preset; read both.
28
+ 5. **Plan.** Write the plan as JSON to a scratch file:
29
+ `{ "screen", "file", "components": [{ "name", "variants": { "<axis>": "<value>" } }] }`,
30
+ one entry per component the screen uses, with the variant values it sets. A
31
+ sub-part (`CardHeader`) comes with its owner (`Card`) and needs no entry. When
32
+ the request needs a component the inventory doesn't have, say so before
33
+ writing and ask: leave it out, use a named existing component instead, or stop.
34
+ The `nearest` names are matched on spelling; suggest the alternative that fits
35
+ by `whenToUse` instead when one does. Never build a lookalike. An icon, or
36
+ anything else the system's package doesn't export, is a gap in the same way:
37
+ the screen imports only from the package and React.
38
+ 6. **Check the plan.** Run the script with `check --system <dir> --package <spec> --plan <plan file>`
39
+ from the repo root. If it returns errors, nothing has been written: fix the
40
+ plan (a `missing-component` error goes back to the user as in step 5) and check
41
+ again. A `name-taken` error means the file at that path wasn't composed for
42
+ this screen: ask for another path, or for the user to move that file. A
43
+ re-run replaces an earlier composed file only when the screen name matches
44
+ its marker exactly, so reuse the name as written. Write
45
+ the variant values in the casing the returned plan uses.
46
+ 7. **Write.** Put the marker `// Composed by throughline: <screen>.` on the first
47
+ line, or on the second after a `'use client'` directive. Import components only
48
+ from the package specifier. Plain elements carry layout only (flex, grid, gap,
49
+ padding, sizing); visible text uses semantic elements (`h1`, `p`, `label`);
50
+ everything the user operates is a system component from the plan (a link may
51
+ be a plain `<a>`). Write colour, spacing, sizing, type size and font weight
52
+ only as token variables
53
+ (`gap-[var(--space-gap-md)]`, `style={{ padding: 'var(--space-4)' }}`) or the
54
+ utilities the system's preset generates from tokens (`bg-primary`,
55
+ `rounded-md`). Never a raw value, and never Tailwind's default scale
56
+ (`gap-4`, `text-lg`, `font-semibold`). Percentages, viewport units,
57
+ fractions (`w-1/2`) and keywords (`w-full`) are layout and fine. The gate
58
+ reads class names anywhere in the file, so visible copy that spells one
59
+ (`text-sm`) fails too; reword the copy rather than the rule. The gate can't
60
+ see a lookalike built from plain elements (a `div` with `role="switch"`), so
61
+ that one is yours to refuse. A size no token holds (a 400px card) can't be bound:
62
+ use a fraction or a keyword and say so in the report. Handlers are stubs; data and state are the app's.
63
+ 8. **Gate.** Run the script with
64
+ `gate --system <dir> --package <spec> --plan <plan file> --file <screen file>`,
65
+ with `--tokens <file>` repeated once per token file.
66
+ On a failure, fix the file and run it again, up to three attempts in all. If
67
+ the third still fails, stop: the file stays where it is, marked, and the
68
+ report lists every failure and says a re-run replaces it. Never change the
69
+ plan or skip a rule to make it pass.
70
+ 9. **Report.** The file's path, whether it replaced an earlier composed one, the
71
+ gate's counts, every gap (each component asked for that doesn't exist, with the
72
+ nearest names the script offered, and what was left out because of it), and
73
+ how to mount the screen where the app picks its screens (its router or entry).
74
+ Don't edit it.
75
+
76
+ Never edit a component, a token or the app's routing. If the screen can't be
77
+ built from what the system has, the gap is the finding.
78
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radicool/throughline",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Build a complete design system end to end — author in Figma, sync tokens to code, generate Storybook. Usable from Claude Code, Cursor, Codex, or any AGENTS.md agent.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/README.md CHANGED
@@ -28,7 +28,7 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
28
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
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) |
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
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) |
33
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 |
34
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` |
@@ -36,6 +36,7 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
36
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` |
37
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
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 |
39
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) |
40
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) |
41
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) |
@@ -255,7 +256,7 @@ the committed `adapters/<target>/` tree plus the runtime payload
255
256
  (`references/` + `scripts/`, minus `scripts/adapters/`) into `.throughline/`,
256
257
  rewriting `${CLAUDE_PLUGIN_ROOT}` → `.throughline`:
257
258
 
258
- npx @radicool/throughline@0.26.0 init --target=cursor|codex|generic
259
+ npx @radicool/throughline@0.27.0 init --target=cursor|codex|generic
259
260
 
260
261
  See `scripts/install.mjs` (pure core + CLI + `install.test.mjs`).
261
262
 
@@ -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();
@@ -104,7 +104,8 @@ function stagePayload(srcRoot, destRoot, skip) {
104
104
  // renderer and the Figma checks reach Figma pre-inlined inside
105
105
  // references/doc-card-builder.md, references/figma-checks.md,
106
106
  // references/figma-adherence.md and references/figma-compose.md). Test fixtures
107
- // under lib/fixtures/ are dropped too.
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,7 +117,6 @@ 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',
119
- 'lib/figma-compose.mjs',
120
120
  'lib/figma-compose.figma.js',
121
121
  ]);
122
122