@radicool/throughline 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +8 -4
  2. package/adapters/codex/AGENTS.md +2 -0
  3. package/adapters/codex/prompts/component-builder.md +5 -3
  4. package/adapters/codex/prompts/compose-code.md +78 -0
  5. package/adapters/codex/prompts/compose-figma.md +59 -0
  6. package/adapters/codex/prompts/document-component.md +3 -2
  7. package/adapters/cursor/.cursor/commands/compose-code.md +78 -0
  8. package/adapters/cursor/.cursor/commands/compose-figma.md +59 -0
  9. package/adapters/cursor/.cursor/commands/document-component.md +3 -2
  10. package/adapters/cursor/.cursor/rules/component-builder.mdc +5 -3
  11. package/adapters/generic/AGENTS.md +2 -0
  12. package/adapters/generic/commands/compose-code.md +78 -0
  13. package/adapters/generic/commands/compose-figma.md +59 -0
  14. package/adapters/generic/commands/document-component.md +3 -2
  15. package/adapters/generic/skills/component-builder/SKILL.md +5 -3
  16. package/package.json +1 -1
  17. package/references/doc-card-builder.md +62 -15
  18. package/references/figma-adherence.md +21 -0
  19. package/references/figma-compose.md +896 -0
  20. package/scripts/README.md +8 -4
  21. package/scripts/build-doc-card-builder.mjs +16 -0
  22. package/scripts/build-figma-checks.mjs +89 -2
  23. package/scripts/compose-code.mjs +271 -0
  24. package/scripts/doc-card-payload.mjs +115 -0
  25. package/scripts/install.mjs +5 -3
  26. package/scripts/lib/doc-card-plan.mjs +37 -0
  27. package/scripts/lib/doc-card-render.figma.js +9 -15
  28. package/scripts/lib/figma-adherence-read.figma.js +21 -0
  29. package/scripts/lib/figma-compose.figma.js +326 -0
  30. package/scripts/lib/figma-compose.mjs +317 -0
  31. package/scripts/lib/fixtures/figma-compose/casing-and-suffix.json +8 -0
  32. package/scripts/lib/fixtures/figma-compose/fill-in-unlaid-slot.json +7 -0
  33. package/scripts/lib/fixtures/figma-compose/hug-on-fixed-component.json +34 -0
  34. package/scripts/lib/fixtures/figma-compose/inventory.json +295 -0
  35. package/scripts/lib/fixtures/figma-compose/many-errors.json +8 -0
  36. package/scripts/lib/fixtures/figma-compose/missing-component.json +7 -0
  37. package/scripts/lib/fixtures/figma-compose/name-taken.json +6 -0
  38. package/scripts/lib/fixtures/figma-compose/padding-sides.json +23 -0
  39. package/scripts/lib/fixtures/figma-compose/radius-kind.json +27 -0
  40. package/scripts/lib/fixtures/figma-compose/raw-number.json +5 -0
  41. package/scripts/lib/fixtures/figma-compose/sign-in-ok.json +16 -0
  42. package/scripts/lib/fixtures/figma-compose/slot-ok.json +9 -0
  43. package/scripts/lib/fixtures/figma-compose/unknown-prop-and-option.json +7 -0
  44. package/scripts/lib/fixtures/figma-compose/wrong-kind.json +5 -0
@@ -14,6 +14,18 @@ it reads the specimen and never writes it. The status chip keeps its own owner
14
14
 
15
15
  ## How to call it
16
16
 
17
+ Steps 1–4 are one command once `figma.docCardVariables` is recorded:
18
+
19
+ ```sh
20
+ node scripts/doc-card-payload.mjs design-system/docs/components/<Name>.doc.json <cardNodeId> --out <scratch>/payload.js
21
+ ```
22
+
23
+ It writes the complete `figma_execute` body — record, fingerprint, the nine
24
+ role lookups by recorded name, `Body/Default`, the snippet and the call — and
25
+ prints the `expectedRenderHash` to stderr. Pass the file's contents as the
26
+ `code` unchanged, then do steps 5–6. The steps below are what it does, and
27
+ what to do by hand when the mapping isn't recorded yet.
28
+
17
29
  1. Load the record and compute its canonical fingerprint in Node
18
30
  (`canonicalFingerprint` in `scripts/lib/doc-record.mjs`).
19
31
  2. Read `figma.docCardVariables` from `design-system.json`.
@@ -82,6 +94,10 @@ const summary = await renderDocCard({ card, record: RECORD, vars, bodyTextStyle
82
94
  `cardWidth` — not from a screenshot, then stamp the manifest from it:
83
95
  `surfaces.docCard = { src: summary.fingerprint, render: summary.renderHash,
84
96
  renderer: summary.rendererVersion }`. Never re-read the card to stamp.
97
+ With the payload script, first check `summary.renderHash` equals the
98
+ `expectedRenderHash` it printed. A mismatch means the payload changed on
99
+ the way in, or `Body/Default` isn't the size the script assumed — the
100
+ summary's `bodyFontSize` says which; re-run with `--font-size`.
85
101
 
86
102
  ## The snippet
87
103
 
@@ -188,20 +204,11 @@ function planDocCard(record, bodyTextStyle) {
188
204
  };
189
205
  }
190
206
 
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.
207
+ // 32-bit FNV-1a — the render hash: fnv1a(JSON.stringify(plan)). Only ever
208
+ // compared against itself (the manifest's surfaces.docCard.render), so it does
209
+ // not need to match the sha256-based canonical fingerprint, which cannot run in
210
+ // the Figma sandbox. Lives here, not in the renderer, so Node can compute the
211
+ // hash a render should return (doc-card-payload.mjs) with the same code.
205
212
  function fnv1a(str) {
206
213
  let h = 0x811c9dc5;
207
214
  for (let i = 0; i < str.length; i++) {
@@ -211,6 +218,40 @@ function fnv1a(str) {
211
218
  return h.toString(16).padStart(8, '0');
212
219
  }
213
220
 
221
+ // When a header's title row has no node named `Status Pill`/`Status`, name the
222
+ // node that most likely IS the chip so the error can say what to rename. Never
223
+ // used to pick a node to write — the renderer still refuses to guess. A
224
+ // candidate is a FRAME below the title row whose only child is a TEXT node
225
+ // (the chip shape: a pill frame around its label). Exactly one candidate or
226
+ // no hint. Reads only type/name/id/characters/children, so it runs on Figma
227
+ // nodes and on plain test objects alike.
228
+ function statusChipHint(titleRow) {
229
+ if (!titleRow || titleRow.type !== 'FRAME') return null;
230
+ const found = [];
231
+ const walk = (node) => {
232
+ for (const child of node.children || []) {
233
+ if (child.type === 'FRAME' && child.children && child.children.length === 1
234
+ && child.children[0].type === 'TEXT') {
235
+ found.push({ id: child.id, name: child.name, label: child.children[0].characters });
236
+ }
237
+ walk(child);
238
+ }
239
+ };
240
+ walk(titleRow);
241
+ return found.length === 1 ? found[0] : null;
242
+ }
243
+
244
+ // Figma plugin-API renderer for the doc-card Usage band. This file is NOT a
245
+ // Node module — build-doc-card-builder.mjs concatenates it after the inlined
246
+ // planner (doc-card-plan.mjs) into references/doc-card-builder.md, and the
247
+ // result runs inside figma_execute (dynamic-page mode). Constraints honored
248
+ // here (see references/figma-scripting.md): async APIs only, style/font set
249
+ // BEFORE .characters, fonts loaded up front, resize() sizing modes re-asserted,
250
+ // bound paints seeded light-gray (never pure black).
251
+ //
252
+ // In-scope globals when assembled: planDocCard, DOC_CARD_RENDERER_VERSION,
253
+ // fnv1a, statusChipHint (inlined planner) and the caller-filled slots RECORD, CANONICAL_FP.
254
+
214
255
  const REQUIRED_VARS = [
215
256
  'textDefault', 'textMuted', 'tonePositive', 'toneNegative', 'border',
216
257
  'spacePadding', 'spaceRowGap', 'spaceBlockGap', 'spaceItemGap',
@@ -341,7 +382,13 @@ async function renderDocCard({ card, record, vars, bodyTextStyle }) {
341
382
  const descAmbiguous = !named && descCandidates.length > 1;
342
383
 
343
384
  const missingAnchors = [];
344
- if (!hasStatusAnchor) missingAnchors.push('status (title row must contain a descendant named "Status Pill" or "Status")');
385
+ if (!hasStatusAnchor) {
386
+ const hint = statusChipHint(titleRow);
387
+ missingAnchors.push('status (title row must contain a descendant named "Status Pill" or "Status"'
388
+ + (hint ? ' — frame ' + hint.id + ' "' + hint.name + '" holding "' + hint.label
389
+ + '" looks like the status chip; rename it "Status Pill" and re-run' : '')
390
+ + ')');
391
+ }
345
392
  if (!headerDescCandidate) {
346
393
  missingAnchors.push(descAmbiguous
347
394
  ? 'description (found ' + descCandidates.length + ' candidate TEXT children, cannot tell which is the description — name the right one "Header Description" by hand and re-run)'
@@ -345,6 +345,19 @@ function overriddenRecords(instance, path, underDetached) {
345
345
  return records;
346
346
  }
347
347
 
348
+ // The SLOT nodes on this instance's own surface: its slots, and the slots of
349
+ // instances nested in its component, reached only through component sublayers
350
+ // (ids 'I…'). A slot inside content added to a slot is left out here; the walk
351
+ // reaches it through that content.
352
+ function slotsOf(instance) {
353
+ return instance.findAll((n) => n.type === 'SLOT').filter((slot) => {
354
+ for (let node = slot.parent; node && node !== instance; node = node.parent) {
355
+ if (!String(node.id).startsWith('I')) return false;
356
+ }
357
+ return true;
358
+ });
359
+ }
360
+
348
361
  function screensOn(page, names) {
349
362
  const all = [];
350
363
  for (const top of page.children) {
@@ -405,6 +418,14 @@ async function readScreens(target) {
405
418
  if (node.visible === false) return;
406
419
  if (node.type === 'INSTANCE') {
407
420
  nodes.push(...overriddenRecords(node, path, insideDetached));
421
+ // Content added to a slot is the screen's own, and isn't listed in
422
+ // instance.overrides. A slot's placeholder children keep instance-sublayer
423
+ // ids ('I…;…') and stay part of the component.
424
+ for (const slot of slotsOf(node)) {
425
+ for (const child of slot.children) {
426
+ if (!String(child.id).startsWith('I')) walk(child, `${path} / ${slot.name} / ${child.name}`, false, insideDetached);
427
+ }
428
+ }
408
429
  return;
409
430
  }
410
431
  const mixedPaints = node.fills === figma.mixed;