@radicool/throughline 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/adapters/codex/AGENTS.md +11 -10
  3. package/adapters/codex/prompts/component-builder.md +107 -0
  4. package/adapters/codex/prompts/design-system-audit.md +20 -0
  5. package/adapters/codex/prompts/document-component.md +58 -0
  6. package/adapters/codex/prompts/repository-builder.md +14 -0
  7. package/adapters/codex/prompts/retrofit-planner.md +21 -1
  8. package/adapters/codex/prompts/storybook-chromatic-builder.md +84 -2
  9. package/adapters/cursor/.cursor/commands/document-component.md +58 -0
  10. package/adapters/cursor/.cursor/rules/component-builder.mdc +108 -1
  11. package/adapters/cursor/.cursor/rules/component-pipeline.mdc +1 -1
  12. package/adapters/cursor/.cursor/rules/design-system-audit.mdc +21 -1
  13. package/adapters/cursor/.cursor/rules/figma-environment-setup.mdc +1 -1
  14. package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +1 -1
  15. package/adapters/cursor/.cursor/rules/repository-builder.mdc +15 -1
  16. package/adapters/cursor/.cursor/rules/retrofit-planner.mdc +22 -2
  17. package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +85 -3
  18. package/adapters/cursor/.cursor/rules/token-builder.mdc +1 -1
  19. package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +1 -1
  20. package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +1 -1
  21. package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +1 -1
  22. package/adapters/generic/AGENTS.md +11 -10
  23. package/adapters/generic/commands/document-component.md +58 -0
  24. package/adapters/generic/skills/component-builder/SKILL.md +107 -0
  25. package/adapters/generic/skills/design-system-audit/SKILL.md +20 -0
  26. package/adapters/generic/skills/repository-builder/SKILL.md +14 -0
  27. package/adapters/generic/skills/retrofit-planner/SKILL.md +21 -1
  28. package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +84 -2
  29. package/package.json +1 -1
  30. package/references/component-doc-archetypes.md +90 -0
  31. package/references/component-doc-schema.md +154 -0
  32. package/references/doc-card-builder.md +565 -0
  33. package/references/doc-writing-standard.md +144 -0
  34. package/references/figma-component-standards.md +63 -16
  35. package/references/guide-voice.md +96 -0
  36. package/references/manifest-schema.md +46 -6
  37. package/scripts/README.md +34 -0
  38. package/scripts/build-doc-card-builder.mjs +143 -0
  39. package/scripts/build-docs-digest.mjs +74 -0
  40. package/scripts/docs-check.mjs +117 -0
  41. package/scripts/docs-lint.mjs +163 -0
  42. package/scripts/install.mjs +13 -1
  43. package/scripts/lib/doc-card-plan.mjs +101 -0
  44. package/scripts/lib/doc-card-render.figma.js +371 -0
  45. package/scripts/lib/doc-record.mjs +54 -0
@@ -0,0 +1,565 @@
1
+ # Doc-card Usage-band builder (GENERATED)
2
+
3
+ > **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/doc-card-plan.mjs`
4
+ > (the pure planner, unit-tested in Node) + `scripts/lib/doc-card-render.figma.js`
5
+ > (the Figma renderer). Regenerate with `node scripts/build-doc-card-builder.mjs`;
6
+ > CI gates freshness with `--check`.
7
+
8
+ The canonical `figma_execute` snippet that renders a component doc card's
9
+ `Usage` band from its `.doc.json` record. Every card is identical by
10
+ construction — never hand-build the usage body. The builder owns the `Usage`
11
+ band and the header's record-derived content (its short description and date);
12
+ it reads the specimen and never writes it. The status chip keeps its own owner
13
+ — the finalize write-back in `references/figma-component-standards.md`.
14
+
15
+ ## How to call it
16
+
17
+ 1. Load the record and compute its canonical fingerprint in Node
18
+ (`canonicalFingerprint` in `scripts/lib/doc-record.mjs`).
19
+ 2. Read `figma.docCardVariables` from `design-system.json`.
20
+ - If present, resolve each of the nine roles to a Variable object **by
21
+ the recorded name** — do not re-derive, do not substitute a similar
22
+ name. Look each name up via `figma_get_variables`, then in the script
23
+ fetch it as a Variable object with
24
+ `figma.variables.getVariableByIdAsync(id)`. If a recorded name no
25
+ longer resolves to exactly one variable in the file, **throw** rather
26
+ than guess — the token was renamed or removed, and silently picking a
27
+ neighbour is how cards drift apart.
28
+ - If the field is absent (a project's first doc-card render, or any
29
+ render after the field is cleared), do not resolve fresh by judgement
30
+ yet — first check whether a doc card already exists in the file. If
31
+ one does, recover all nine roles from it by resolving each bound
32
+ variable id back to its name (`figma.variables.getVariableByIdAsync(id)`):
33
+ - `spacePadding` ← the `Usage` frame's `paddingLeft`.
34
+ - `spaceRowGap` ← the `Usage` frame's `itemSpacing`.
35
+ - `spaceBlockGap` ← a `Usage Row *` frame's `itemSpacing`.
36
+ - `spaceItemGap` ← a `Block: *` frame's `itemSpacing` (blocks are the
37
+ children of a `Usage Row *`).
38
+ - `border` ← a `Row Divider` frame's
39
+ `fills[0].boundVariables.color`.
40
+ - `tonePositive` ← the first TEXT child of the `Block: Do` frame's
41
+ `fills[0].boundVariables.color`.
42
+ - `toneNegative` ← the first TEXT child of the `Block: Don't` frame,
43
+ same property.
44
+ - `textMuted` ← the first TEXT child of any block other than
45
+ `Block: Do` / `Block: Don't`, same property (tone blocks colour
46
+ their eyebrow differently, so exclude them here).
47
+ - `textDefault` ← the second child of that same block when it is a
48
+ TEXT node — `Block: Overview` is reliable; definition blocks nest
49
+ frames there instead, so skip those. Same property.
50
+ A single-row card has no `Row Divider` (no `border`); a card without
51
+ `Block: Do` / `Block: Don't` yields no `tonePositive` / `toneNegative`.
52
+ Read another rendered card for the roles that specific card can't
53
+ yield, or fall back to judgement for just those. Only when no
54
+ rendered card exists at all does the caller choose every role by
55
+ judgement — establishing the project's rhythm, not guessing at one.
56
+ Either way, resolve the nine roles once, **write the mapping back to
57
+ `design-system.json`** as `figma.docCardVariables`, then render. Every
58
+ later render reads it.
59
+ The nine roles: `textDefault`, `textMuted` (text colors), `tonePositive`,
60
+ `toneNegative` (Do/Don't eyebrow colors — success/danger roles), `border`
61
+ (row dividers), `spacePadding`, `spaceRowGap`, `spaceBlockGap`,
62
+ `spaceItemGap` (spacing roles: band padding, row gap, block gutter,
63
+ within-block gap).
64
+ 3. Find the body text style: `(await figma.getLocalTextStylesAsync())
65
+ .find((s) => s.name === 'Body/Default')`. Missing variables or style =
66
+ the builder throws (bind-or-throw — the gap is in the token set; fix it
67
+ there, never hardcode around it).
68
+ 4. Prepend the two slots, then the snippet below, then the call:
69
+
70
+ ```js
71
+ const RECORD = /* the parsed .doc.json object */;
72
+ const CANONICAL_FP = '/* canonicalFingerprint(RECORD), 16 hex chars */';
73
+ // … the generated snippet …
74
+ const card = await figma.getNodeByIdAsync(cardNodeId);
75
+ const summary = await renderDocCard({ card, record: RECORD, vars, bodyTextStyle });
76
+ ```
77
+
78
+ 5. Pass an explicit `timeout` (30000 is right for one card; the ~30s
79
+ `figma_execute` ceiling fits a single card comfortably — render cards one
80
+ call at a time, never batched).
81
+ 6. Verify from the returned summary — `rowsRendered`, `blocksCreated`,
82
+ `cardWidth` — not from a screenshot, then stamp the manifest from it:
83
+ `surfaces.docCard = { src: summary.fingerprint, render: summary.renderHash,
84
+ renderer: summary.rendererVersion }`. Never re-read the card to stamp.
85
+
86
+ ## The snippet
87
+
88
+ ```js
89
+ // Pure layout planner for the component doc card's Usage band.
90
+ // ZERO imports, `export const`/`export function` only — this module is inlined
91
+ // verbatim into the generated Figma snippet (references/doc-card-builder.md) by
92
+ // build-doc-card-builder.mjs, so it must run in both Node and the Figma plugin
93
+ // sandbox. build-doc-card-builder.mjs enforces the no-imports rule.
94
+ //
95
+ // Layout contract: docs/superpowers/specs/2026-08-09-doc-card-layout-and-voice-design.md
96
+
97
+ // Single source of truth for the doc-card layout version. Imported by
98
+ // docs-check.mjs and embedded (via inlining) into the generated builder snippet.
99
+ const DOC_CARD_RENDERER_VERSION = '4';
100
+
101
+ // columnUnit = clamp(round(bodyFontSize × 30), 280, 480) px.
102
+ // 30 ≈ 60ch × ~0.5em average glyph width for UI text faces. Layout chrome, not
103
+ // a design value — the one documented exception to the no-hardcoded-px rule.
104
+ function columnUnit(bodyFontSize) {
105
+ return Math.min(480, Math.max(280, Math.round(bodyFontSize * 30)));
106
+ }
107
+
108
+ // columns = max(max blocks in any row, 3). Content alone decides: the grid
109
+ // never mints a column no row can fill, and never drops below the 3-unit floor.
110
+ // The specimen is deliberately NOT an input — the render widens the card, the
111
+ // card's hug propagates into FILL siblings including the specimen, so any
112
+ // specimen measurement is a value this render mutates and the next one reads.
113
+ function cardColumns(maxBlocksPerRow) {
114
+ return Math.max(3, maxBlocksPerRow);
115
+ }
116
+
117
+ function listBlock(eyebrow, items) {
118
+ if (!Array.isArray(items) || items.length === 0) return null;
119
+ return { type: 'list', name: `Block: ${eyebrow}`, eyebrow, items };
120
+ }
121
+
122
+ function definitionBlock(eyebrow, meanings) {
123
+ const terms = Object.keys(meanings || {}).map((k) => ({ term: k, meaning: meanings[k] }));
124
+ if (terms.length === 0) return null;
125
+ return { type: 'definition', name: `Block: ${eyebrow}`, eyebrow, terms };
126
+ }
127
+
128
+ // The whole layout decision, as data. Rows keep canonical numbering (an absent
129
+ // row's number is skipped, never renumbered) so node names stay stable across
130
+ // sparse records. bodyTextStyle: only .fontSize is read — passing a full Figma
131
+ // TextStyle object is fine.
132
+ function planDocCard(record, bodyTextStyle) {
133
+ const unit = columnUnit(bodyTextStyle.fontSize);
134
+
135
+ const row1 = [];
136
+ if (typeof record.description === 'string' && record.description.trim() !== '') {
137
+ row1.push({ type: 'prose', name: 'Block: Overview', eyebrow: 'Overview', text: record.description });
138
+ }
139
+ const whenTo = listBlock('When to use', record.whenToUse);
140
+ if (whenTo) row1.push(whenTo);
141
+ const whenNot = listBlock('When not to use', record.whenNotToUse);
142
+ if (whenNot) row1.push(whenNot);
143
+
144
+ const row2 = [];
145
+ if (Array.isArray(record.dos) && record.dos.length) {
146
+ row2.push({ type: 'list-tone', name: 'Block: Do', eyebrow: '✓ Do', tone: 'positive', items: record.dos });
147
+ }
148
+ if (Array.isArray(record.donts) && record.donts.length) {
149
+ row2.push({ type: 'list-tone', name: "Block: Don't", eyebrow: "✕ Don't", tone: 'negative', items: record.donts });
150
+ }
151
+
152
+ const row3 = [];
153
+ for (const axis of Object.keys(record.variants || {})) {
154
+ const block = definitionBlock(`What each ${axis} means`, record.variants[axis]);
155
+ if (block) row3.push(block);
156
+ }
157
+ const stateBlock = definitionBlock('What each state means', record.states);
158
+ if (stateBlock) row3.push(stateBlock);
159
+ const a11y = record.accessibility || {};
160
+ // role is not rendered on the card — it lives in the description field / MDX.
161
+ const a11yBlock = listBlock('Accessibility', [...(a11y.keyboard || []), ...(a11y.notes || [])]);
162
+ if (a11yBlock) row3.push(a11yBlock);
163
+
164
+ const rows = [
165
+ { name: 'Usage Row 1', blocks: row1 },
166
+ { name: 'Usage Row 2', blocks: row2 },
167
+ { name: 'Usage Row 3', blocks: row3 },
168
+ ].filter((r) => r.blocks.length > 0);
169
+
170
+ const maxBlocksPerRow = rows.reduce((m, r) => Math.max(m, r.blocks.length), 0);
171
+ const columns = cardColumns(maxBlocksPerRow);
172
+
173
+ return {
174
+ rendererVersion: DOC_CARD_RENDERER_VERSION,
175
+ columnUnit: unit,
176
+ columns,
177
+ cardWidth: columns * unit,
178
+ termColumn: Math.round(unit * 0.3),
179
+ // The header band's record-derived content. Carried in the plan (not read
180
+ // straight off the record by the renderer) so renderHash describes every
181
+ // string the builder writes onto the card, header included. Always strings:
182
+ // an undefined would drop the key from JSON.stringify and move the hash.
183
+ header: {
184
+ summary: typeof record.summary === 'string' ? record.summary : '',
185
+ updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : '',
186
+ },
187
+ rows,
188
+ };
189
+ }
190
+
191
+ // Figma plugin-API renderer for the doc-card Usage band. This file is NOT a
192
+ // Node module — build-doc-card-builder.mjs concatenates it after the inlined
193
+ // planner (doc-card-plan.mjs) into references/doc-card-builder.md, and the
194
+ // result runs inside figma_execute (dynamic-page mode). Constraints honored
195
+ // here (see references/figma-scripting.md): async APIs only, style/font set
196
+ // BEFORE .characters, fonts loaded up front, resize() sizing modes re-asserted,
197
+ // bound paints seeded light-gray (never pure black).
198
+ //
199
+ // In-scope globals when assembled: planDocCard, DOC_CARD_RENDERER_VERSION
200
+ // (inlined planner) and the caller-filled slots RECORD, CANONICAL_FP.
201
+
202
+ // 32-bit FNV-1a — the render hash. Only ever compared against itself (the
203
+ // manifest's surfaces.docCard.render), so it does not need to match the
204
+ // sha256-based canonical fingerprint, which cannot run in the Figma sandbox.
205
+ function fnv1a(str) {
206
+ let h = 0x811c9dc5;
207
+ for (let i = 0; i < str.length; i++) {
208
+ h ^= str.charCodeAt(i);
209
+ h = Math.imul(h, 0x01000193) >>> 0;
210
+ }
211
+ return h.toString(16).padStart(8, '0');
212
+ }
213
+
214
+ const REQUIRED_VARS = [
215
+ 'textDefault', 'textMuted', 'tonePositive', 'toneNegative', 'border',
216
+ 'spacePadding', 'spaceRowGap', 'spaceBlockGap', 'spaceItemGap',
217
+ ];
218
+
219
+ // Bound paint, seeded with a light-gray approximation — a failed/late bind must
220
+ // never render pure black (reads as accidental dark mode).
221
+ function boundPaint(variable) {
222
+ return figma.variables.setBoundVariableForPaint(
223
+ { type: 'SOLID', color: { r: 0.85, g: 0.85, b: 0.87 } }, 'color', variable,
224
+ );
225
+ }
226
+
227
+ async function renderDocCard({ card, record, vars, bodyTextStyle }) {
228
+ // Bind-or-throw: a missing variable or style is a gap in the token set —
229
+ // never fall back to a hex/px.
230
+ for (const key of REQUIRED_VARS) {
231
+ if (!vars || !vars[key]) {
232
+ throw new Error('renderDocCard: missing required variable "' + key
233
+ + '" — resolve it via figma_get_variables / getVariableByIdAsync and pass the Variable object in vars');
234
+ }
235
+ }
236
+ if (!bodyTextStyle) {
237
+ throw new Error('renderDocCard: bodyTextStyle is required — find the "Body/Default" text style via getLocalTextStylesAsync');
238
+ }
239
+
240
+ // Three-band cards are VERTICAL auto-layout frames. Appending into anything
241
+ // else preserves absolute position and mis-places the band — throw instead.
242
+ if (card.layoutMode !== 'VERTICAL') {
243
+ throw new Error('renderDocCard: the doc card must be a VERTICAL auto-layout frame (three-band card); got layoutMode=' + card.layoutMode);
244
+ }
245
+
246
+ // Fonts, up front — before any text node exists. Eyebrow chrome is Bold of
247
+ // the body family; fall back to the body style's own font if no Bold exists.
248
+ const bodyFont = bodyTextStyle.fontName;
249
+ await figma.loadFontAsync(bodyFont);
250
+ let eyebrowFont = { family: bodyFont.family, style: 'Bold' };
251
+ try { await figma.loadFontAsync(eyebrowFont); } catch (e) { eyebrowFont = bodyFont; }
252
+
253
+ // One component per doc card: a band like "Usage — Select Menu Item" means this
254
+ // card documents multiple components. Rendering here would append a band we
255
+ // don't own and silently accumulate — refuse and ask for the card to be split.
256
+ // Checked before the specimen lookup so a multi-component card always gets
257
+ // this error, never a possible "no COMPONENT_SET found" from the lookup below.
258
+ const foreign = card.findChild((n) => n.name !== 'Usage' && n.name.startsWith('Usage'));
259
+ if (foreign) {
260
+ throw new Error('renderDocCard: card contains band "' + foreign.name
261
+ + '" — one component per doc card; split this card so each component owns its own card before re-rendering');
262
+ }
263
+
264
+ // Structural contract only: the card must contain a COMPONENT_SET. It is
265
+ // deliberately NOT measured — the render widens the card, the card's hug
266
+ // propagates into FILL siblings including the specimen, so any specimen
267
+ // measurement is a value this render mutates and the next one reads. (No
268
+ // named "Specimen" band lookup: no real card has ever used one, so that
269
+ // path never executed.)
270
+ const specimen = card.findOne((n) => n.type === 'COMPONENT_SET');
271
+ if (!specimen) {
272
+ throw new Error('renderDocCard: no COMPONENT_SET found inside the card — the specimen band must contain the component set');
273
+ }
274
+
275
+ // The header band's record-derived content is written further below (after
276
+ // the Usage band rebuild), but its shape is validated here — before ANY
277
+ // mutation — so a shape mismatch throws with the card untouched. The
278
+ // builder owns this content: the status write-back only fires on a status
279
+ // change, so a re-voiced component that is already `stable` would otherwise
280
+ // keep its original blurb and date forever. The status chip itself is NOT
281
+ // touched — the finalize write-back still owns it.
282
+ //
283
+ // The header band's own name is unreliable (`Header` on Button, `Frame` on
284
+ // the other 12 cards measured), so it is located structurally instead — the
285
+ // card's child FRAME that is neither the `Usage` band nor the specimen nor
286
+ // an ancestor of it.
287
+ const headerBand = card.children.find((n) =>
288
+ n.type === 'FRAME'
289
+ && n.name !== 'Usage'
290
+ && n.id !== specimen.id
291
+ && !n.findOne((d) => d.id === specimen.id));
292
+ if (!headerBand) {
293
+ throw new Error('renderDocCard: no header band found — expected a child frame holding the component name, description, status chip and date');
294
+ }
295
+
296
+ // Two accepted header shapes (figma-component-standards.md "The header"):
297
+ // legacy cards carry a `Status Pill` descendant plus a label/value date
298
+ // frame; to-spec cards (built by /new-component per the written standard)
299
+ // carry `Status`/`Status Label` plus a `Last Updated` TEXT node. Neither is
300
+ // going away, so both are located structurally rather than by fixed
301
+ // child-index, resolving to the same three anchors below.
302
+ const titleRow = headerBand.children[0];
303
+ const hasStatusAnchor = !!(titleRow && titleRow.type === 'FRAME'
304
+ && titleRow.findOne((d) => d.name === 'Status Pill' || d.name === 'Status'));
305
+
306
+ // Date anchor. To-spec: a direct TEXT child of the header band named
307
+ // `Last Updated` — that node IS the value. Legacy: a FRAME child whose
308
+ // first child is TEXT reading exactly "Last updated"; the value is that
309
+ // frame's other TEXT child, found by elimination against the label rather
310
+ // than assumed by index.
311
+ let dateValue = headerBand.children.find((n) => n.type === 'TEXT' && n.name === 'Last Updated');
312
+ if (!dateValue) {
313
+ const legacyDateFrame = headerBand.children.find((n) =>
314
+ n.type === 'FRAME' && n.children[0] && n.children[0].type === 'TEXT'
315
+ && n.children[0].characters === 'Last updated');
316
+ if (legacyDateFrame) {
317
+ const dateLabel = legacyDateFrame.children[0];
318
+ dateValue = legacyDateFrame.children.find((n) => n !== dateLabel && n.type === 'TEXT');
319
+ }
320
+ }
321
+
322
+ // Description anchor: the header band's own bare description TEXT node — a
323
+ // direct TEXT child that is neither the title row nor the resolved date
324
+ // node. Not assumed by fixed index: the to-spec shape's child count can
325
+ // differ from the legacy 3-child shape.
326
+ //
327
+ // A node already named `Header Description` (a prior run's rename) is
328
+ // unambiguous by construction, so it wins outright. Failing that the
329
+ // candidates must resolve to EXACTLY ONE: picking the first of several
330
+ // would, on a header that also exposes its component-name TEXT as a direct
331
+ // child, overwrite that name with the summary and then rename it — wrong,
332
+ // destructive, and self-perpetuating on every later run. A visible date
333
+ // LABEL sibling (the "Last updated" caption, distinct from the value node
334
+ // resolved above) is excluded rather than counted, so the to-spec shape
335
+ // that carries one is still accepted instead of being falsely rejected.
336
+ const named = headerBand.children.find((n) => n.type === 'TEXT' && n.name === 'Header Description');
337
+ const descCandidates = headerBand.children.filter((n) =>
338
+ n !== titleRow && n.type === 'TEXT' && n !== dateValue
339
+ && n.name !== 'Last Updated' && n.characters !== 'Last updated');
340
+ const headerDescCandidate = named || (descCandidates.length === 1 ? descCandidates[0] : null);
341
+ const descAmbiguous = !named && descCandidates.length > 1;
342
+
343
+ const missingAnchors = [];
344
+ if (!hasStatusAnchor) missingAnchors.push('status (title row must contain a descendant named "Status Pill" or "Status")');
345
+ if (!headerDescCandidate) {
346
+ missingAnchors.push(descAmbiguous
347
+ ? 'description (found ' + descCandidates.length + ' candidate TEXT children, cannot tell which is the description — name the right one "Header Description" by hand and re-run)'
348
+ : 'description (a bare TEXT child distinct from the title row and the date node)');
349
+ }
350
+ if (!dateValue) missingAnchors.push('date (either a "Last Updated" TEXT child, or a FRAME child whose first TEXT child reads "Last updated")');
351
+ if (missingAnchors.length) {
352
+ throw new Error('renderDocCard: header band does not match either accepted shape — missing anchor(s): '
353
+ + missingAnchors.join('; ')
354
+ + ' — refusing to guess which node to write; see "The header" in figma-component-standards.md for the two accepted shapes');
355
+ }
356
+
357
+ const plan = planDocCard(record, { fontSize: bodyTextStyle.fontSize });
358
+
359
+ // Eyebrow chrome (derived, not bound — layout chrome like the column unit):
360
+ // fontSize × 0.65 rounded, min 8; Bold; uppercase; letter-spacing +8%.
361
+ const eyebrowSize = Math.max(8, Math.round(bodyTextStyle.fontSize * 0.65));
362
+
363
+ // Idempotent + scoped: rebuild ONLY the Usage frame. The specimen is never touched
364
+ // — recreating a component set detaches downstream instances. (The header's
365
+ // record-derived content is written separately, below.)
366
+ const existing = card.findChild((n) => n.name === 'Usage');
367
+ if (existing) existing.remove();
368
+
369
+ const eyebrowText = (chars, colorVar) => {
370
+ const t = figma.createText();
371
+ t.fontName = eyebrowFont; // loaded above — set BEFORE .characters
372
+ t.fontSize = eyebrowSize;
373
+ t.letterSpacing = { value: 8, unit: 'PERCENT' };
374
+ t.textCase = 'UPPER';
375
+ t.characters = chars;
376
+ t.fills = [boundPaint(colorVar)];
377
+ return t;
378
+ };
379
+
380
+ const bodyText = async (chars, colorVar) => {
381
+ const t = figma.createText();
382
+ await t.setTextStyleIdAsync(bodyTextStyle.id); // style BEFORE characters
383
+ t.characters = chars;
384
+ t.fills = [boundPaint(colorVar)];
385
+ return t;
386
+ };
387
+
388
+ // Appends `t` to `parent` as a full-width, height-hugging text node.
389
+ const fillWidth = (parent, t) => {
390
+ parent.appendChild(t);
391
+ t.textAutoResize = 'HEIGHT';
392
+ t.layoutSizingHorizontal = 'FILL';
393
+ };
394
+
395
+ // Resolved px of the spacing tokens (mode-aware, resolved against the card).
396
+ // The planner's cardWidth is the CONTENT-GRID width (columns × columnUnit);
397
+ // the frame's outer width adds the band padding and the inter-block gutters
398
+ // so the planned column count actually fits on one line.
399
+ const padPx = vars.spacePadding.resolveForConsumer(card).value;
400
+ const blockGapPx = vars.spaceBlockGap.resolveForConsumer(card).value;
401
+ const usageWidth = plan.cardWidth + 2 * padPx + (plan.columns - 1) * blockGapPx;
402
+
403
+ const usage = figma.createFrame();
404
+ usage.name = 'Usage';
405
+ usage.layoutMode = 'VERTICAL';
406
+ usage.fills = [];
407
+ usage.clipsContent = false;
408
+ card.appendChild(usage);
409
+ usage.resize(usageWidth, usage.height);
410
+ usage.counterAxisSizingMode = 'FIXED'; // VERTICAL frame: counter = width
411
+ usage.primaryAxisSizingMode = 'AUTO'; // height hugs — re-asserted after resize()
412
+ usage.setBoundVariable('paddingLeft', vars.spacePadding);
413
+ usage.setBoundVariable('paddingRight', vars.spacePadding);
414
+ usage.setBoundVariable('paddingTop', vars.spacePadding);
415
+ usage.setBoundVariable('paddingBottom', vars.spacePadding);
416
+ usage.setBoundVariable('itemSpacing', vars.spaceRowGap);
417
+
418
+ // Widen the card to fit (its own padding included). Card is VERTICAL (guarded
419
+ // above): counter axis = width, so a hugging card needs no resize; a fixed
420
+ // card is widened and its height sizing re-asserted after resize().
421
+ const cardOuter = usageWidth + card.paddingLeft + card.paddingRight;
422
+ if (card.counterAxisSizingMode !== 'AUTO' && card.width < cardOuter) {
423
+ card.resize(cardOuter, card.height);
424
+ card.primaryAxisSizingMode = 'AUTO';
425
+ }
426
+
427
+ const blocksCreated = [];
428
+ let first = true;
429
+ for (const row of plan.rows) {
430
+ if (!first) {
431
+ const divider = figma.createFrame();
432
+ divider.name = 'Row Divider';
433
+ divider.fills = [boundPaint(vars.border)];
434
+ usage.appendChild(divider);
435
+ divider.resize(divider.width, 1);
436
+ divider.layoutSizingHorizontal = 'FILL';
437
+ }
438
+ first = false;
439
+
440
+ const rowFrame = figma.createFrame();
441
+ rowFrame.name = row.name;
442
+ rowFrame.layoutMode = 'HORIZONTAL';
443
+ rowFrame.layoutWrap = 'WRAP';
444
+ rowFrame.fills = [];
445
+ rowFrame.counterAxisAlignItems = 'MIN'; // top-aligned…
446
+ rowFrame.primaryAxisAlignItems = 'MIN'; // …left-packed; never center/space-between
447
+ usage.appendChild(rowFrame);
448
+ rowFrame.layoutSizingHorizontal = 'FILL';
449
+ rowFrame.layoutSizingVertical = 'HUG';
450
+ rowFrame.setBoundVariable('itemSpacing', vars.spaceBlockGap);
451
+ rowFrame.setBoundVariable('counterAxisSpacing', vars.spaceRowGap);
452
+
453
+ for (const block of row.blocks) {
454
+ const bf = figma.createFrame();
455
+ bf.name = block.name;
456
+ bf.layoutMode = 'VERTICAL';
457
+ bf.fills = [];
458
+ rowFrame.appendChild(bf);
459
+ bf.resize(plan.columnUnit, bf.height); // every block is exactly one unit wide
460
+ bf.counterAxisSizingMode = 'FIXED';
461
+ bf.primaryAxisSizingMode = 'AUTO';
462
+ bf.setBoundVariable('itemSpacing', vars.spaceItemGap);
463
+
464
+ const eyebrowColor = block.type === 'list-tone'
465
+ ? (block.tone === 'positive' ? vars.tonePositive : vars.toneNegative)
466
+ : vars.textMuted;
467
+ const eb = eyebrowText(block.eyebrow, eyebrowColor);
468
+ bf.appendChild(eb);
469
+ eb.textAutoResize = 'HEIGHT';
470
+ eb.layoutSizingHorizontal = 'FILL';
471
+
472
+ if (block.type === 'prose') {
473
+ fillWidth(bf, await bodyText(block.text, vars.textDefault));
474
+ } else if (block.type === 'list' || block.type === 'list-tone') {
475
+ for (const item of block.items) {
476
+ fillWidth(bf, await bodyText('• ' + item, vars.textDefault));
477
+ }
478
+ } else if (block.type === 'definition') {
479
+ for (const pair of block.terms) {
480
+ const pf = figma.createFrame();
481
+ pf.name = 'Definition: ' + pair.term;
482
+ pf.layoutMode = 'HORIZONTAL';
483
+ pf.fills = [];
484
+ bf.appendChild(pf);
485
+ pf.layoutSizingHorizontal = 'FILL';
486
+ pf.layoutSizingVertical = 'HUG';
487
+ pf.setBoundVariable('itemSpacing', vars.spaceItemGap);
488
+ const term = await bodyText(pair.term, vars.textDefault);
489
+ pf.appendChild(term);
490
+ term.textAutoResize = 'HEIGHT'; // long terms wrap, never truncate
491
+ term.resize(plan.termColumn, term.height);
492
+ term.layoutSizingHorizontal = 'FIXED'; // fixed term column: 30% of the unit
493
+ const meaning = await bodyText(pair.meaning, vars.textMuted);
494
+ pf.appendChild(meaning);
495
+ meaning.textAutoResize = 'HEIGHT';
496
+ meaning.layoutSizingHorizontal = 'FILL';
497
+ }
498
+ }
499
+ blocksCreated.push(block.name);
500
+ }
501
+ }
502
+
503
+ // Metadata node — hidden, machine-read by the drift check's Figma-side pass.
504
+ const fp = eyebrowText(CANONICAL_FP, vars.textMuted);
505
+ fp.name = 'Doc Fingerprint';
506
+ fp.visible = false;
507
+ usage.appendChild(fp);
508
+
509
+ // The header band's record-derived content — its shape (status, description,
510
+ // and date anchors) was already validated above, before the Usage band
511
+ // rebuild.
512
+
513
+ // Load a text node's own fonts before writing, and never touch its style:
514
+ // the header's type is card chrome, not part of this projection. A
515
+ // zero-length node can't have mixed fonts (getRangeAllFontNames(0, 1) would
516
+ // exceed the text and throw), so it takes its own path via .fontName.
517
+ const writeChars = async (node, chars) => {
518
+ const len = node.characters.length;
519
+ if (len === 0) {
520
+ await figma.loadFontAsync(node.fontName);
521
+ } else {
522
+ for (const f of node.getRangeAllFontNames(0, len)) await figma.loadFontAsync(f);
523
+ }
524
+ node.characters = chars;
525
+ };
526
+
527
+ // Self-migrating: the anchor resolved above already preferred a node named
528
+ // `Header Description` over a positional match, so renaming here makes
529
+ // every later run deterministic by name rather than by position.
530
+ const headerDesc = headerDescCandidate;
531
+ if (headerDesc.name !== 'Header Description') headerDesc.name = 'Header Description';
532
+ // record.summary is schema-required but the renderer never calls
533
+ // validateRecord() itself — an unvalidated record's default ('') must not
534
+ // silently blank a live card's description.
535
+ if (plan.header.summary) await writeChars(headerDesc, plan.header.summary);
536
+ // Re-assert the one-column clamp (figma-component-standards.md): the header
537
+ // description never stretches across a wide matrix. Layout, not content —
538
+ // re-applied on every render regardless of whether the text changed.
539
+ headerDesc.textAutoResize = 'HEIGHT';
540
+ headerDesc.resize(plan.columnUnit, headerDesc.height);
541
+ headerDesc.layoutSizingHorizontal = 'FIXED';
542
+
543
+ // Single source for the header date: record.updatedAt (via plan.header —
544
+ // see figma-component-standards.md "Last updated"). `dateValue` was
545
+ // resolved above, before the Usage rebuild, under either header shape.
546
+ if (dateValue && plan.header.updatedAt) {
547
+ await writeChars(dateValue, plan.header.updatedAt);
548
+ }
549
+
550
+ return {
551
+ rendererVersion: DOC_CARD_RENDERER_VERSION,
552
+ columnUnit: plan.columnUnit,
553
+ columns: plan.columns,
554
+ cardWidth: plan.cardWidth,
555
+ rowsRendered: plan.rows.length,
556
+ blocksCreated,
557
+ headerWritten: true,
558
+ fingerprint: CANONICAL_FP,
559
+ renderHash: fnv1a(JSON.stringify(plan)),
560
+ };
561
+ }
562
+ ```
563
+
564
+ Layout contract and rationale:
565
+ `docs/superpowers/specs/2026-08-09-doc-card-layout-and-voice-design.md`.