@radicool/throughline 0.14.0 → 0.16.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 (49) hide show
  1. package/README.md +2 -1
  2. package/adapters/codex/AGENTS.md +10 -10
  3. package/adapters/codex/prompts/component-builder.md +59 -15
  4. package/adapters/codex/prompts/document-component.md +42 -10
  5. package/adapters/codex/prompts/storybook-chromatic-builder.md +52 -9
  6. package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
  7. package/adapters/codex/prompts/token-sync-layer.md +64 -4
  8. package/adapters/cursor/.cursor/commands/document-component.md +42 -10
  9. package/adapters/cursor/.cursor/rules/component-builder.mdc +60 -16
  10. package/adapters/cursor/.cursor/rules/component-pipeline.mdc +1 -1
  11. package/adapters/cursor/.cursor/rules/design-system-audit.mdc +1 -1
  12. package/adapters/cursor/.cursor/rules/figma-environment-setup.mdc +1 -1
  13. package/adapters/cursor/.cursor/rules/icon-system-builder.mdc +1 -1
  14. package/adapters/cursor/.cursor/rules/repository-builder.mdc +1 -1
  15. package/adapters/cursor/.cursor/rules/retrofit-planner.mdc +1 -1
  16. package/adapters/cursor/.cursor/rules/storybook-chromatic-builder.mdc +53 -10
  17. package/adapters/cursor/.cursor/rules/token-builder.mdc +1 -1
  18. package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +3 -1
  19. package/adapters/cursor/.cursor/rules/token-sheet-builder.mdc +1 -1
  20. package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +65 -5
  21. package/adapters/generic/AGENTS.md +10 -10
  22. package/adapters/generic/commands/document-component.md +42 -10
  23. package/adapters/generic/skills/component-builder/SKILL.md +59 -15
  24. package/adapters/generic/skills/storybook-chromatic-builder/SKILL.md +52 -9
  25. package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
  26. package/adapters/generic/skills/token-sync-layer/SKILL.md +64 -4
  27. package/package.json +1 -1
  28. package/references/component-doc-archetypes.md +15 -11
  29. package/references/component-doc-schema.md +23 -5
  30. package/references/doc-card-builder.md +565 -0
  31. package/references/doc-writing-standard.md +144 -0
  32. package/references/figma-component-standards.md +63 -16
  33. package/references/guide-voice.md +96 -0
  34. package/references/manifest-schema.md +24 -6
  35. package/references/native-adapter-config.md +930 -0
  36. package/references/sync-adapters.md +94 -12
  37. package/scripts/README.md +37 -3
  38. package/scripts/build-doc-card-builder.mjs +143 -0
  39. package/scripts/build-native-adapter-config.mjs +280 -0
  40. package/scripts/docs-check.mjs +18 -4
  41. package/scripts/docs-lint.mjs +163 -0
  42. package/scripts/install.mjs +14 -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/dtcg.mjs +87 -0
  46. package/scripts/lib/native-literal.mjs +205 -0
  47. package/scripts/lib/sd-native.mjs +770 -0
  48. package/scripts/validate-crosswalk.mjs +3 -29
  49. package/scripts/validate-token-output.mjs +338 -0
@@ -35,16 +35,54 @@ user names one of these, use the vetted preset — high confidence, no guessing.
35
35
  | `tailwind` | tailwind theme config | `dark:` variant / class strategy | preserved (via CSS vars) | `colors.background` |
36
36
  | `mui` | JS theme object | `createTheme` palettes | preserved (object refs) | `palette.primary.main` |
37
37
  | `vanilla-css` | one CSS file | `:root` + `[data-theme]` | preserved | `--color-bg-default` |
38
- | `ios-swift` | Swift enum / asset catalog | light/dark asset variants | flattened | `Color.backgroundPrimary` |
39
-
40
- These five were chosen for coverage of this plugin's web-first, design-led
41
- audience: three React framework adapters (shadcn — the dominant new-project
42
- choice; standalone Tailwind — for the large Tailwind-without-shadcn population;
43
- MUI — the enterprise/Material standard), the universal `vanilla-css` escape
44
- hatch (plain CSS custom properties, no framework), and one native slot
45
- (`ios-swift`, the more standardized native pattern). Everything else
46
- Ant Design, Chakra, HeroUI, Android/Kotlin, Flutter, React Native, etc. — is
47
- fully supported via Tier 2.
38
+ | `ios-swift` | Swift enum constants (`Tokens.swift`) | one build per mode, one output directory per mode | flattened | `Tokens.textSm` |
39
+
40
+ Four of the five cover this plugin's web-first, design-led audience: three React
41
+ framework adapters (shadcn — the dominant new-project choice; standalone
42
+ Tailwind — for the large Tailwind-without-shadcn population; MUI — the
43
+ enterprise/Material standard), plus the universal `vanilla-css` escape hatch
44
+ (plain CSS custom properties, no framework). `ios-swift` is the one native
45
+ member, and it is curated because its configuration ships as tested code rather
46
+ than as advice see `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`.
47
+ Everything else Ant Design, Chakra, HeroUI, Android/Kotlin, Flutter, React
48
+ Native, etc. — is fully supported via Tier 2.
49
+
50
+ **`ios-swift` was demoted, and has been restored.** Run against a real DTCG
51
+ source under the *stock* transform group it emitted px-authored dimensions at
52
+ ×16 their authored value (valid, compiling Swift), leaked `color-mix()`
53
+ expressions, and left dual-node aliases as bare `px` literals. The tier badge
54
+ claimed a confidence the stock configuration had not earned, so it came off.
55
+
56
+ The cause was the transform group, not the adapter concept. That configuration
57
+ now ships as tested code at
58
+ `${CLAUDE_PLUGIN_ROOT}/scripts/lib/sd-native.mjs`, is installed into the
59
+ consumer's repo, and is verified end to end against a real source (195 emitted
60
+ symbols matched, zero rule failures under the shipping ruleset, on both light
61
+ and dark builds). The badge is back on that basis.
62
+
63
+ **What the badge does not cover: nothing is compiled.** Every emitted value is
64
+ checked to be a well-formed Swift or Kotlin *literal* — `tokens:validate-output`'s
65
+ `invalid-literal` rule parses each one and fails on anything that is not
66
+ (#53) — but no `swiftc` or `kotlinc` runs, so a type mismatch or a call to an
67
+ undefined symbol would still pass. String-valued tokens (`fontFamily`, `string`,
68
+ keyword `fontWeight`) are quoted by the module's own transforms, and a value with
69
+ no native form at all — a CSS `linear-gradient(...)` — is filtered out of native
70
+ output rather than emitted broken. That drop is reported as an unemitted token,
71
+ not hidden.
72
+
73
+ `android-kotlin` uses the same module and stays Tier 2: its remaining unknowns
74
+ are on the consumption side — Compose `dp`/`sp` behaviour against a real Compose
75
+ app, resource-qualifier conventions, package layout — which building tokens does
76
+ not exercise. The `dp`/`sp` split itself is no longer among them: font sizes and
77
+ line heights whose role a DTCG source states — the `fontSize`, `letterSpacing`
78
+ and `lineHeight` member names of §9.8's typography composite — now emit as
79
+ `sp`. What remains is narrower and documented in
80
+ `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`: a bare scale
81
+ primitive carries no role and stays `dp`, and an `em` letterSpacing is
82
+ filtered out rather than emitted as `.em`.
83
+ `tokens:validate-output` remains what decides whether any adapter can be
84
+ trusted, and re-promotion is available to any adapter that passes it against a
85
+ real source.
48
86
 
49
87
  `shadcn` vs `tailwind`: shadcn emits CSS vars *plus* the specific var names
50
88
  shadcn components expect; `tailwind` targets Tailwind used on its own, mapping
@@ -99,8 +137,52 @@ emit as references to primitive vars rather than flattened literals.
99
137
  - **Native adapters** (`ios-swift`, and generated native targets like
100
138
  Android/Kotlin): `outputReferences: false` — references resolve to literal
101
139
  values at build time, because the target language has no runtime var
102
- indirection. Modes map to the platform's native mechanism (asset catalog
103
- variants, resource qualifiers).
140
+ indirection. Modes map to **one build per mode, one output directory per
141
+ mode** — each build passing only that mode's sources, through `nativeSources`.
142
+ Asset-catalog variants and resource qualifiers are *not* implemented: nothing
143
+ here emits an `.xcassets` catalog or a `values-night/` resource tree.
144
+
145
+ ## What the stock configuration gets wrong
146
+
147
+ These are all legal in a real DTCG source, and a **stock** Style Dictionary
148
+ transform group mishandles every one of them silently — emitting output that
149
+ compiles and is wrong, which is why `tokens:validate-output` exists.
150
+
151
+ **None of these are Style Dictionary limitations.** All four are fixed by
152
+ roughly 80 lines of preprocessor and transform code, which ships as a tested
153
+ module at `${CLAUDE_PLUGIN_ROOT}/scripts/lib/sd-native.mjs` and is documented in
154
+ `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`. Against a real
155
+ 322-token source it emitted 195 symbols that all map to a source token, 107 of
156
+ them with their numeric magnitude additionally verified, with zero rule failures
157
+ — colour and string values are matched by name only and are checked by no rule.
158
+ **Import that module** rather than configuring a native adapter from a stock
159
+ `transformGroup` or transcribing the reference doc.
160
+
161
+ - **CSS expressions in a value** — `color-mix(in srgb, {color.brand.500} 12%,
162
+ transparent)` is a runtime CSS construct. Style Dictionary does no colour
163
+ math, so it resolves only the inner reference and leaves the function wrapper
164
+ in the output. *Fix: a transform that computes the blend to a literal.*
165
+ - **Dual-node tokens** — a node carrying both a `$value` and children (`text.sm`
166
+ with `$value: "14px"` plus a `text.sm.lineHeight` child). Style Dictionary's
167
+ resolver will not traverse into one, so every alias to the child fails to
168
+ resolve and emits as a bare literal; its collector also stops there, so the
169
+ child is never emitted at all. *Fix: a preprocessor that resolves aliases and
170
+ hoists the children.*
171
+ - **`%` and `em` dimensions** — parent-relative or container-relative, so there
172
+ genuinely is no build-time native magnitude. This one is a real limit rather
173
+ than a configuration gap. *Handling: filter them out of native builds — on the
174
+ authored value, since a `100%` token may be typed `string`, not `dimension`.*
175
+ - **A third mode axis** — this reference models theme (`.dark` /
176
+ `[data-theme]`) and brand (`[data-brand]`). A viewport axis carrying its own
177
+ spacing and type scales is common and has no mapping here; on native it is
178
+ size classes and resource qualifiers, resolved by a different mechanism
179
+ entirely.
180
+
181
+ **Native dimension transforms must read the authored unit.** The stock
182
+ `ios-swift` and `compose` transform groups assume `rem` input and multiply by
183
+ 16. Against a `px`-authored source that silently produces output at sixteen
184
+ times scale which compiles and ships. Emit 1:1 for `px` and unitless ratios;
185
+ ×16 only for `rem`.
104
186
 
105
187
  ## Brownfield value transforms
106
188
 
package/scripts/README.md CHANGED
@@ -10,10 +10,38 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
10
10
  | `validate-crosswalk.mjs` | Resolve every `newToken` against the DTCG token source; assert resolved value == `newValue`, N/N. The CI gate. | `tokens:validate` |
11
11
  | `build-reverse-index.mjs` | Emit a `codeToken -> newToken` map from the crosswalk to semi-automate SCSS/Tailwind swaps. | `tokens:reverse-index` |
12
12
  | `guard-token-removal.mjs` | Grep `.ts/.tsx` (minus generated + tests) for about-to-be-deleted symbols; blocks cleanup until zero references remain. | run during the cleanup phase |
13
+ | `validate-token-output.mjs` | Assert generated native token output matches its DTCG source: authored-unit fidelity, no leaked CSS syntax, no bare unit literals, no mode collisions. Fails when no emitted symbol matches a source token, and reports match rate, unparsed lines, and unemitted tokens on every run. | `tokens:validate-output` |
13
14
  | `lib/crosswalk.mjs` | Shared loader + structural validation for `crosswalk.json` (used by the validator and reverse-index). | copied alongside |
15
+ | `lib/dtcg.mjs` | Shared DTCG flatten + `{alias}` resolution. Dual-node aware: a node carrying both a `$value` and children yields its own value **and** is descended into. Used by `validate-crosswalk.mjs` and `validate-token-output.mjs`. | copied alongside both |
16
+ | `lib/sd-native.mjs` | The Style Dictionary native configuration as code: unit-aware dimension transforms, `color-mix` computation, dual-node preprocessing, platform assembly, and a per-mode source guard. Style Dictionary is a parameter, never an import. | copied alongside `validate-token-output.mjs` |
17
+ | `lib/native-literal.mjs` | Shared grammar for "is this a well-formed Swift or Kotlin literal": parses rather than pattern-matches, so an unquoted string, a raw CSS function, or any other unanticipated case fails the same way. Used by `sd-native.mjs`'s output filter and by `validate-token-output.mjs`'s invalid-literal rule. | copied alongside `validate-token-output.mjs` and `lib/sd-native.mjs` |
14
18
  | `crosswalk.schema.json` | The finalized JSON Schema for `crosswalk.json` (contract + editor support). | copied beside `crosswalk.json` |
15
19
  | `build-docs-digest.mjs` | Aggregate every `design-system/docs/components/*.doc.json` into `design-system/docs/index.json` + `llms.txt` for AI/human consumers. | `docs:digest` |
16
20
  | `docs-check.mjs` | Drift gate — verifies each component's doc surfaces still match its canonical record (via `lib/doc-record.mjs` fingerprints). Exits 1 on drift. | `docs:check` |
21
+ | `docs-lint.mjs` | Copy lint for .doc.json records — warnings only, always exits 0 on a parseable record; the mechanical subset of `references/doc-writing-standard.md`. | `docs:lint` |
22
+ | `lib/doc-record.mjs` | Canonical record load + `canonicalFingerprint` (sha256 over the record minus `provenance`). The fingerprint every surface is stamped with. | copied alongside docs-check.mjs |
23
+ | `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) |
24
+ | `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 |
25
+ | `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) |
26
+ | `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) |
27
+
28
+ **Documentation scripts — install as a set.** Copying these files without
29
+ registering them leaves a repo with a script on disk and no entry point, which
30
+ is how a stale `docs:check` went unnoticed for a full release. Both
31
+ `storybook-chromatic-builder` (first-time setup) and `/document-component`
32
+ (freshness refresh) install the same five files and register the same three
33
+ scripts:
34
+
35
+ | File | npm script |
36
+ | --- | --- |
37
+ | `build-docs-digest.mjs` | `"docs:digest": "node scripts/build-docs-digest.mjs"` |
38
+ | `docs-check.mjs` | `"docs:check": "node scripts/docs-check.mjs"` |
39
+ | `docs-lint.mjs` | `"docs:lint": "node scripts/docs-lint.mjs"` |
40
+ | `lib/doc-record.mjs` | — (imported by the above) |
41
+ | `lib/doc-card-plan.mjs` | — (imported by the above) |
42
+
43
+ A refresh that adds a file must also add its npm script; check `package.json`
44
+ for all three every time, not just the file that changed.
17
45
 
18
46
  The crosswalk contract is documented in
19
47
  `${CLAUDE_PLUGIN_ROOT}/references/crosswalk-schema.md`.
@@ -31,9 +59,9 @@ conflict, or remaining reference), `2` bad CLI arguments.
31
59
 
32
60
  ## How the skill installs these
33
61
 
34
- `token-crosswalk-builder` copies `lib/crosswalk.mjs`, `validate-crosswalk.mjs`,
35
- `build-reverse-index.mjs`, `guard-token-removal.mjs`, and `crosswalk.schema.json`
36
- into the user's `packages/tokens/scripts/` (schema beside `crosswalk.json`), then
62
+ `token-crosswalk-builder` copies `lib/crosswalk.mjs`, `lib/dtcg.mjs`,
63
+ `validate-crosswalk.mjs`, `build-reverse-index.mjs`, `guard-token-removal.mjs`, and
64
+ `crosswalk.schema.json` into the user's `packages/tokens/scripts/` (schema beside `crosswalk.json`), then
37
65
  wires `packages/tokens/package.json`:
38
66
 
39
67
  ```jsonc
@@ -43,6 +71,12 @@ wires `packages/tokens/package.json`:
43
71
  }
44
72
  ```
45
73
 
74
+ `token-sync-layer` copies `validate-token-output.mjs`, `lib/dtcg.mjs`,
75
+ `lib/native-literal.mjs`, **and** `lib/sd-native.mjs`, and wires
76
+ `"tokens:validate-output"`. All four travel together: `sd-native.mjs` and the
77
+ validator both import `lib/dtcg.mjs` and `lib/native-literal.mjs`, so
78
+ installing any one of them alone breaks at import time.
79
+
46
80
  The scripts version with the user's repo so their CI runs them locally — a path
47
81
  inside the plugin install would not be reachable from the user's CI.
48
82
 
@@ -0,0 +1,143 @@
1
+ // Generates references/doc-card-builder.md — the canonical figma_execute
2
+ // snippet that renders a doc card's Usage band — by inlining the pure planner
3
+ // (lib/doc-card-plan.mjs) above the Figma renderer template
4
+ // (lib/doc-card-render.figma.js). Mirrors the adapters generate.mjs idiom:
5
+ // run bare to write, run with --check to gate CI. Zero dependencies.
6
+ import { readFileSync, writeFileSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+
10
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const PLANNER = join(REPO_ROOT, 'scripts', 'lib', 'doc-card-plan.mjs');
12
+ const RENDERER = join(REPO_ROOT, 'scripts', 'lib', 'doc-card-render.figma.js');
13
+ const OUT = join(REPO_ROOT, 'references', 'doc-card-builder.md');
14
+
15
+ const HEADER = [
16
+ '# Doc-card Usage-band builder (GENERATED)',
17
+ '',
18
+ '> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/doc-card-plan.mjs`',
19
+ '> (the pure planner, unit-tested in Node) + `scripts/lib/doc-card-render.figma.js`',
20
+ '> (the Figma renderer). Regenerate with `node scripts/build-doc-card-builder.mjs`;',
21
+ '> CI gates freshness with `--check`.',
22
+ '',
23
+ 'The canonical `figma_execute` snippet that renders a component doc card\'s',
24
+ '`Usage` band from its `.doc.json` record. Every card is identical by',
25
+ 'construction — never hand-build the usage body. The builder owns the `Usage`',
26
+ 'band and the header\'s record-derived content (its short description and date);',
27
+ 'it reads the specimen and never writes it. The status chip keeps its own owner',
28
+ '— the finalize write-back in `references/figma-component-standards.md`.',
29
+ '',
30
+ '## How to call it',
31
+ '',
32
+ '1. Load the record and compute its canonical fingerprint in Node',
33
+ ' (`canonicalFingerprint` in `scripts/lib/doc-record.mjs`).',
34
+ '2. Read `figma.docCardVariables` from `design-system.json`.',
35
+ ' - If present, resolve each of the nine roles to a Variable object **by',
36
+ ' the recorded name** — do not re-derive, do not substitute a similar',
37
+ ' name. Look each name up via `figma_get_variables`, then in the script',
38
+ ' fetch it as a Variable object with',
39
+ ' `figma.variables.getVariableByIdAsync(id)`. If a recorded name no',
40
+ ' longer resolves to exactly one variable in the file, **throw** rather',
41
+ ' than guess — the token was renamed or removed, and silently picking a',
42
+ ' neighbour is how cards drift apart.',
43
+ ' - If the field is absent (a project\'s first doc-card render, or any',
44
+ ' render after the field is cleared), do not resolve fresh by judgement',
45
+ ' yet — first check whether a doc card already exists in the file. If',
46
+ ' one does, recover all nine roles from it by resolving each bound',
47
+ ' variable id back to its name (`figma.variables.getVariableByIdAsync(id)`):',
48
+ ' - `spacePadding` ← the `Usage` frame\'s `paddingLeft`.',
49
+ ' - `spaceRowGap` ← the `Usage` frame\'s `itemSpacing`.',
50
+ ' - `spaceBlockGap` ← a `Usage Row *` frame\'s `itemSpacing`.',
51
+ ' - `spaceItemGap` ← a `Block: *` frame\'s `itemSpacing` (blocks are the',
52
+ ' children of a `Usage Row *`).',
53
+ ' - `border` ← a `Row Divider` frame\'s',
54
+ ' `fills[0].boundVariables.color`.',
55
+ ' - `tonePositive` ← the first TEXT child of the `Block: Do` frame\'s',
56
+ ' `fills[0].boundVariables.color`.',
57
+ ' - `toneNegative` ← the first TEXT child of the `Block: Don\'t` frame,',
58
+ ' same property.',
59
+ ' - `textMuted` ← the first TEXT child of any block other than',
60
+ ' `Block: Do` / `Block: Don\'t`, same property (tone blocks colour',
61
+ ' their eyebrow differently, so exclude them here).',
62
+ ' - `textDefault` ← the second child of that same block when it is a',
63
+ ' TEXT node — `Block: Overview` is reliable; definition blocks nest',
64
+ ' frames there instead, so skip those. Same property.',
65
+ ' A single-row card has no `Row Divider` (no `border`); a card without',
66
+ ' `Block: Do` / `Block: Don\'t` yields no `tonePositive` / `toneNegative`.',
67
+ ' Read another rendered card for the roles that specific card can\'t',
68
+ ' yield, or fall back to judgement for just those. Only when no',
69
+ ' rendered card exists at all does the caller choose every role by',
70
+ ' judgement — establishing the project\'s rhythm, not guessing at one.',
71
+ ' Either way, resolve the nine roles once, **write the mapping back to',
72
+ ' `design-system.json`** as `figma.docCardVariables`, then render. Every',
73
+ ' later render reads it.',
74
+ ' The nine roles: `textDefault`, `textMuted` (text colors), `tonePositive`,',
75
+ ' `toneNegative` (Do/Don\'t eyebrow colors — success/danger roles), `border`',
76
+ ' (row dividers), `spacePadding`, `spaceRowGap`, `spaceBlockGap`,',
77
+ ' `spaceItemGap` (spacing roles: band padding, row gap, block gutter,',
78
+ ' within-block gap).',
79
+ '3. Find the body text style: `(await figma.getLocalTextStylesAsync())',
80
+ ' .find((s) => s.name === \'Body/Default\')`. Missing variables or style =',
81
+ ' the builder throws (bind-or-throw — the gap is in the token set; fix it',
82
+ ' there, never hardcode around it).',
83
+ '4. Prepend the two slots, then the snippet below, then the call:',
84
+ '',
85
+ '```js',
86
+ 'const RECORD = /* the parsed .doc.json object */;',
87
+ 'const CANONICAL_FP = \'/* canonicalFingerprint(RECORD), 16 hex chars */\';',
88
+ '// … the generated snippet …',
89
+ 'const card = await figma.getNodeByIdAsync(cardNodeId);',
90
+ 'const summary = await renderDocCard({ card, record: RECORD, vars, bodyTextStyle });',
91
+ '```',
92
+ '',
93
+ '5. Pass an explicit `timeout` (30000 is right for one card; the ~30s',
94
+ ' `figma_execute` ceiling fits a single card comfortably — render cards one',
95
+ ' call at a time, never batched).',
96
+ '6. Verify from the returned summary — `rowsRendered`, `blocksCreated`,',
97
+ ' `cardWidth` — not from a screenshot, then stamp the manifest from it:',
98
+ ' `surfaces.docCard = { src: summary.fingerprint, render: summary.renderHash,',
99
+ ' renderer: summary.rendererVersion }`. Never re-read the card to stamp.',
100
+ '',
101
+ '## The snippet',
102
+ '',
103
+ '```js',
104
+ ].join('\n');
105
+
106
+ const FOOTER = [
107
+ '```',
108
+ '',
109
+ 'Layout contract and rationale:',
110
+ '`docs/superpowers/specs/2026-08-09-doc-card-layout-and-voice-design.md`.',
111
+ '',
112
+ ].join('\n');
113
+
114
+ export function buildDocCardBuilder({ plannerSource, rendererSource }) {
115
+ const inlined = plannerSource
116
+ .replace(/^export const /gm, 'const ')
117
+ .replace(/^export function /gm, 'function ');
118
+ for (const [name, src] of [['doc-card-plan.mjs', inlined], ['doc-card-render.figma.js', rendererSource]]) {
119
+ if (/^\s*(import|export)\b/m.test(src)) {
120
+ throw new Error(`${name} must stay import-free (only top-level \`export const\`/\`export function\` allowed in the planner) — it is inlined into the Figma snippet where no module system exists`);
121
+ }
122
+ }
123
+ return `${HEADER}\n${inlined}\n${rendererSource}${FOOTER}`;
124
+ }
125
+
126
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
127
+ const result = buildDocCardBuilder({
128
+ plannerSource: readFileSync(PLANNER, 'utf8'),
129
+ rendererSource: readFileSync(RENDERER, 'utf8'),
130
+ });
131
+ if (process.argv.includes('--check')) {
132
+ let onDisk = null;
133
+ try { onDisk = readFileSync(OUT, 'utf8'); } catch (e) { /* missing counts as drift */ }
134
+ if (onDisk !== result) {
135
+ console.error('✗ references/doc-card-builder.md out of date; run: node scripts/build-doc-card-builder.mjs');
136
+ process.exit(1);
137
+ }
138
+ console.log('✓ doc-card builder in sync');
139
+ } else {
140
+ writeFileSync(OUT, result);
141
+ console.log('✓ wrote references/doc-card-builder.md');
142
+ }
143
+ }
@@ -0,0 +1,280 @@
1
+ // Generates references/native-adapter-config.md from scripts/lib/sd-native.mjs.
2
+ // The prose lives here, keyed by section id; the code is sliced out of the
3
+ // module's real source between @doc-section markers and interleaved beneath its
4
+ // prose. Mirrors build-doc-card-builder.mjs: run bare to write, --check to gate
5
+ // CI. Zero dependencies.
6
+ import { readFileSync, writeFileSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+
10
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
11
+ export const SOURCE = join(REPO_ROOT, 'scripts', 'lib', 'sd-native.mjs');
12
+ export const OUT = join(REPO_ROOT, 'references', 'native-adapter-config.md');
13
+
14
+ const OPEN = /^\s*\/\/\s*@doc-section\s+(\S+)\s*$/;
15
+ const CLOSE = /^\s*\/\/\s*@doc-section-end\s+(\S+)\s*$/;
16
+
17
+ export function sliceSections(source) {
18
+ const sections = new Map();
19
+ let open = null;
20
+ let buffer = [];
21
+ for (const line of source.split('\n')) {
22
+ const closeMatch = line.match(CLOSE);
23
+ if (closeMatch) {
24
+ if (!open) throw new Error(`@doc-section-end ${closeMatch[1]} with no open section`);
25
+ if (closeMatch[1] !== open) {
26
+ throw new Error(`@doc-section-end ${closeMatch[1]} does not close ${open}`);
27
+ }
28
+ sections.set(open, buffer.join('\n').trim());
29
+ open = null;
30
+ buffer = [];
31
+ continue;
32
+ }
33
+ const openMatch = line.match(OPEN);
34
+ if (openMatch) {
35
+ if (open) throw new Error(`unclosed @doc-section ${open}`);
36
+ if (sections.has(openMatch[1])) throw new Error(`duplicate @doc-section ${openMatch[1]}`);
37
+ open = openMatch[1];
38
+ continue;
39
+ }
40
+ if (open) buffer.push(line);
41
+ }
42
+ if (open) throw new Error(`unclosed @doc-section ${open}`);
43
+ return sections;
44
+ }
45
+
46
+ // Every line of module source must live inside some @doc-section pair, so that
47
+ // adding a piece to the module without documenting it breaks CI rather than
48
+ // silently shipping a doc whose code does not run. Legitimately outside: blank
49
+ // lines, `//` comment lines (the file's leading banner), and the markers
50
+ // themselves. Anything else — any line with executable code on it — is a gap.
51
+ export function assertCovered(source) {
52
+ const uncovered = [];
53
+ let open = false;
54
+ source.split('\n').forEach((line, i) => {
55
+ if (CLOSE.test(line)) {
56
+ open = false;
57
+ return;
58
+ }
59
+ if (OPEN.test(line)) {
60
+ open = true;
61
+ return;
62
+ }
63
+ if (open) return;
64
+ const trimmed = line.trim();
65
+ if (trimmed === '' || trimmed.startsWith('//')) return;
66
+ uncovered.push(` ${i + 1}: ${line}`);
67
+ });
68
+ if (uncovered.length === 0) return;
69
+ throw new Error(
70
+ `${uncovered.length} line(s) of ${SOURCE} fall outside every @doc-section pair.\n` +
71
+ 'Only blank lines and // comments may sit outside a section; code must be\n' +
72
+ 'inside one, or the generated doc shows code that cannot run.\n' +
73
+ uncovered.join('\n'),
74
+ );
75
+ }
76
+
77
+ // Section id -> the prose that introduces it. Order here is the doc's order.
78
+ const PROSE = [
79
+ ['imports', `## Imports
80
+
81
+ \`node:fs\` plus the siblings \`lib/dtcg.mjs\` and \`lib/native-literal.mjs\` this
82
+ plugin already installs — nothing else. Style Dictionary is passed in as a
83
+ parameter, never imported, which is what keeps this module installable into a
84
+ consumer's repo.`],
85
+ ['unit-aware', `## 1. Read the authored unit
86
+
87
+ **This replaces \`size/swift/remToCGFloat\` and the \`size/compose/*\` transforms,
88
+ and it is the single most important piece.** Those assume every dimension is
89
+ authored in \`rem\` and multiply by 16. Against a \`px\`-authored source that
90
+ silently produces output at sixteen times scale which compiles and ships.`],
91
+ ['color-mix', `## 2. Compute \`color-mix()\` to a literal
92
+
93
+ A CSS expression has no native equivalent, and Style Dictionary does no colour
94
+ math. Native adapters resolve to literals; for a \`color-mix\` that means
95
+ actually computing the blend. Register this **before** the platform's colour
96
+ transform, so the colour transform receives a valid hex8 rather than a CSS
97
+ function.`],
98
+ ['preprocess', `## 3. Resolve aliases and hoist dual-node children
99
+
100
+ Style Dictionary's resolver will not traverse into a node that carries both a
101
+ \`$value\` and children, and its collector stops there too. The dual-node pattern
102
+ is invalid DTCG — the Design Tokens Format Module's 30 July 2026 draft, §6.1,
103
+ requires tools to report it as an error, and §6.2's \`$root\` is the sanctioned
104
+ way to pair a value with children. Figma-derived sources emit it anyway:
105
+ \`text.sm\` holds \`$value: "14px"\` *and* a \`text.sm.lineHeight\` child. So every
106
+ alias to such a child fails to resolve, and the child is never emitted at all.
107
+
108
+ Both are fixed before Style Dictionary sees the tree.`],
109
+ ['platform', `## 4. Assemble the platform from the stock list
110
+
111
+ Build the transform list from Style Dictionary's **stock group**, replacing only
112
+ the rem-assuming size transforms. A hand-picked list silently drops whatever it
113
+ forgets — three real defects arose exactly that way.
114
+
115
+ **The \`dp\`/\`sp\` split is fixed here; two narrower Android-only limits remain.**
116
+ Style Dictionary's Compose transforms select on \`$type\`, and DTCG's type set
117
+ does not line up with what they expect — there is no \`fontSize\` type, because
118
+ DTCG types font sizes as \`dimension\`. So the role is taken instead from the
119
+ member names DTCG §9.8 fixes for the typography composite, stamped onto
120
+ \`$extensions\` during preprocessing, and the two Compose transforms partition on
121
+ that stamp. Measured against a real source: 39 declarations that emitted \`dp\`
122
+ now emit \`sp\`, with the Swift output byte-identical.
123
+
124
+ What remains:
125
+
126
+ - **A bare scale primitive emits as \`dp\`.** \`text.base: "16px"\` is a font size
127
+ only to a human — no nominal or structural signal marks it — so it is not
128
+ stamped. The semantic tokens that reference it are, and those are what a
129
+ consumer should reach for.
130
+ - **An \`em\` letter spacing reaches Compose but not Swift.** \`size/unit-aware/compose-em\`
131
+ emits it as a real \`.em\` TextUnit, parenthesised — \`(-0.03).em\` — because
132
+ \`-0.03.em\` parses as \`-(0.03.em)\` and needs an \`unaryMinus\` operator, while
133
+ the parenthesised form compiles regardless. iOS is excluded deliberately, not
134
+ pending: letter spacing there is an \`NSAttributedString\` kern in points,
135
+ which needs the font size the token does not carry, so no constant Swift
136
+ could emit would be right at every font size.
137
+
138
+ The first is Android-only. \`size/unit-aware/swift\` filters
139
+ \`dimension || fontSize\` and emits \`CGFloat\`, which carries no unit to be wrong
140
+ about; iOS handles Dynamic Type at the use site via \`UIFontMetrics\`.
141
+ \`tokens:validate-output\` passes in both cases: it checks magnitude, not unit.
142
+
143
+ **A unitless value is no longer one of them.** DTCG §8.2.1 requires a dimension
144
+ to carry a unit, §8.7's \`number\` is the type for a ratio, and §9.8 types
145
+ \`lineHeight\` as one — so \`leading.normal: "1.5"\` typed \`dimension\` is malformed
146
+ input. No size transform claims it: it emits bare on both platforms, which is
147
+ byte-for-byte what a correctly typed \`number\` already produced, so correcting
148
+ the source's \`$type\` changes no output. \`tokens:validate-output\` reports it as
149
+ a \`unitless-dimension\` advisory, which does not gate — the emitted value is
150
+ right under the ratio reading, and only the author can say whether a ratio is
151
+ what was meant.
152
+
153
+ **The stock list is accounted for, not transcribed.** \`PLATFORMS\` records the
154
+ stock group each platform mirrors, and \`auditStockGroups\` checks at
155
+ registration that every transform in that live group is either run here or
156
+ declined in writing, with a reason. A stock transform this config has never
157
+ decided about warns; it is never silently dropped. The check warns and never
158
+ throws — a new stock transform is usually harmless, and the fatal direction, a
159
+ transform we run being removed, already makes Style Dictionary throw on an
160
+ unknown name. It runs in your build because that is the only place the
161
+ installed Style Dictionary version is knowable: ThroughLine declares no
162
+ dependency on it.`],
163
+ ['sources', `## 5. Guard the per-mode source list
164
+
165
+ Style Dictionary deduplicates by dot-path, so one build over both a light and a
166
+ dark definition of the same token keeps whichever file sorts last and drops the
167
+ other mode with no diagnostic. Pass every build's sources through
168
+ \`nativeSources\`, which returns them, so the check cannot be skipped by
169
+ forgetting it.`],
170
+ ['register', `## 6. Register with Style Dictionary
171
+
172
+ One call. Style Dictionary is a parameter, never an import, which is what lets
173
+ this module install into a consumer's \`packages/tokens/scripts/lib/\`.`],
174
+ ];
175
+
176
+ const HEADER = `# Native adapter configuration (GENERATED)
177
+
178
+ > **GENERATED FILE — do not edit by hand.** Source: \`scripts/lib/sd-native.mjs\`,
179
+ > which is unit-tested in Node and installed into the consumer's repo.
180
+ > Regenerate with \`node scripts/build-native-adapter-config.mjs\`; CI gates
181
+ > freshness with \`--check\`.
182
+
183
+ The Style Dictionary configuration a native adapter (\`ios-swift\`,
184
+ \`android-kotlin\`, or any generated native target) needs in order to emit
185
+ **correct** output from a real DTCG token source.
186
+
187
+ **Why this exists.** The stock \`ios-swift\` and \`compose\` transform groups
188
+ produce output that compiles and is wrong. Run against a real source, the stock
189
+ configuration emitted every \`px\`-authored dimension at ×16 its authored value,
190
+ leaked \`color-mix()\` expressions into Swift, and left dual-node aliases as bare
191
+ \`px\` literals — all at exit \`0\`. None of that is a Style Dictionary limitation.
192
+ All of it is configuration.
193
+
194
+ **You do not need to copy any of this.** It ships as
195
+ \`\${CLAUDE_PLUGIN_ROOT}/scripts/lib/sd-native.mjs\`. Install it beside
196
+ \`lib/dtcg.mjs\` and \`lib/native-literal.mjs\` and call it:
197
+
198
+ \`\`\`js
199
+ import StyleDictionary from 'style-dictionary';
200
+ import { registerNativeTransforms, nativePlatform, nativeSources }
201
+ from './scripts/lib/sd-native.mjs';
202
+
203
+ registerNativeTransforms(StyleDictionary);
204
+
205
+ for (const mode of ['light', 'dark']) {
206
+ const sd = new StyleDictionary({
207
+ source: nativeSources(sourcesFor(mode)),
208
+ preprocessors: ['dtcg/resolve-dual-node'],
209
+ platforms: {
210
+ ios: nativePlatform({ platform: 'ios-swift', buildPath: \`out/\${mode}/\` }),
211
+ },
212
+ });
213
+ await sd.buildAllPlatforms();
214
+ }
215
+ \`\`\`
216
+
217
+ The sections below are the module's own source, inlined so the configuration
218
+ stays reviewable. Pair this with \`\${CLAUDE_PLUGIN_ROOT}/references/sync-adapters.md\`,
219
+ which covers the adapter contract itself.
220
+ `;
221
+
222
+ const FOOTER = `## Verify, always
223
+
224
+ Configuration this specific is exactly what regresses unnoticed, because every
225
+ failure mode above produces output that compiles. Run \`tokens:validate-output\`
226
+ against each generated file with the same source list that file's build used,
227
+ and treat it as a gate rather than a spot check:
228
+
229
+ \`\`\`
230
+ node scripts/validate-token-output.mjs \\
231
+ --source tokens/color-primitives.json --source tokens/text-primitives.json \\
232
+ --output out/light/Tokens.swift --platform ios-swift --min-match 1
233
+ \`\`\`
234
+
235
+ A clean run reports 100% of emitted symbols matched with zero rule failures.
236
+ Anything less means the configuration drifted — so **pass \`--min-match 1\`**.
237
+ The flag's default is \`0.5\`, which is a floor against wholly unparseable output
238
+ rather than the gate this doc describes; without it a 60% match rate exits \`0\`.
239
+ See \`\${CLAUDE_PLUGIN_ROOT}/scripts/README.md\`.
240
+
241
+ "Matched" means an emitted symbol's name resolved to a source token. Numeric
242
+ magnitudes are additionally compared; colour and string values are matched by
243
+ name only, and no rule checks that the output compiles.
244
+ `;
245
+
246
+ export function render(sections) {
247
+ const declared = PROSE.map(([id]) => id);
248
+ for (const id of declared) {
249
+ if (!sections.has(id)) throw new Error(`@doc-section ${id} is declared in PROSE but missing from ${SOURCE}`);
250
+ }
251
+ for (const id of sections.keys()) {
252
+ if (!declared.includes(id)) throw new Error(`@doc-section ${id} exists in ${SOURCE} but has no prose entry`);
253
+ }
254
+ const body = PROSE.map(([id, prose]) => `${prose}\n\n\`\`\`js\n${sections.get(id)}\n\`\`\`\n`).join('\n');
255
+ return `${HEADER}\n${body}\n${FOOTER}`;
256
+ }
257
+
258
+ function main() {
259
+ const check = process.argv.includes('--check');
260
+ const source = readFileSync(SOURCE, 'utf8');
261
+ assertCovered(source);
262
+ const rendered = render(sliceSections(source));
263
+ if (!check) {
264
+ writeFileSync(OUT, rendered);
265
+ console.log(`wrote ${OUT}`);
266
+ return;
267
+ }
268
+ const current = readFileSync(OUT, 'utf8');
269
+ if (current === rendered) {
270
+ console.log('references/native-adapter-config.md is up to date');
271
+ return;
272
+ }
273
+ console.error(
274
+ 'references/native-adapter-config.md is stale.\n' +
275
+ 'Run: node scripts/build-native-adapter-config.mjs',
276
+ );
277
+ process.exit(1);
278
+ }
279
+
280
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();