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