@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.
- package/README.md +6 -4
- package/adapters/codex/AGENTS.md +1 -0
- package/adapters/codex/prompts/component-builder.md +5 -3
- package/adapters/codex/prompts/compose-figma.md +59 -0
- package/adapters/codex/prompts/document-component.md +3 -2
- package/adapters/cursor/.cursor/commands/compose-figma.md +59 -0
- package/adapters/cursor/.cursor/commands/document-component.md +3 -2
- package/adapters/cursor/.cursor/rules/component-builder.mdc +5 -3
- package/adapters/generic/AGENTS.md +1 -0
- package/adapters/generic/commands/compose-figma.md +59 -0
- package/adapters/generic/commands/document-component.md +3 -2
- package/adapters/generic/skills/component-builder/SKILL.md +5 -3
- package/package.json +1 -1
- package/references/doc-card-builder.md +62 -15
- package/references/figma-adherence.md +21 -0
- package/references/figma-compose.md +896 -0
- package/scripts/README.md +7 -4
- package/scripts/build-doc-card-builder.mjs +16 -0
- package/scripts/build-figma-checks.mjs +89 -2
- package/scripts/doc-card-payload.mjs +115 -0
- package/scripts/install.mjs +4 -2
- package/scripts/lib/doc-card-plan.mjs +37 -0
- package/scripts/lib/doc-card-render.figma.js +9 -15
- package/scripts/lib/figma-adherence-read.figma.js +21 -0
- package/scripts/lib/figma-compose.figma.js +326 -0
- package/scripts/lib/figma-compose.mjs +317 -0
- package/scripts/lib/fixtures/figma-compose/casing-and-suffix.json +8 -0
- package/scripts/lib/fixtures/figma-compose/fill-in-unlaid-slot.json +7 -0
- package/scripts/lib/fixtures/figma-compose/hug-on-fixed-component.json +34 -0
- package/scripts/lib/fixtures/figma-compose/inventory.json +295 -0
- package/scripts/lib/fixtures/figma-compose/many-errors.json +8 -0
- package/scripts/lib/fixtures/figma-compose/missing-component.json +7 -0
- package/scripts/lib/fixtures/figma-compose/name-taken.json +6 -0
- package/scripts/lib/fixtures/figma-compose/padding-sides.json +23 -0
- package/scripts/lib/fixtures/figma-compose/radius-kind.json +27 -0
- package/scripts/lib/fixtures/figma-compose/raw-number.json +5 -0
- package/scripts/lib/fixtures/figma-compose/sign-in-ok.json +16 -0
- package/scripts/lib/fixtures/figma-compose/slot-ok.json +9 -0
- package/scripts/lib/fixtures/figma-compose/unknown-prop-and-option.json +7 -0
- 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
|
|
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`);
|
|
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.
|
|
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
|
-
//
|
|
12
|
-
//
|
|
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
|
+
}
|
package/scripts/install.mjs
CHANGED
|
@@ -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
|
|
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)
|
|
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;
|