@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
@@ -0,0 +1,770 @@
1
+ // Style Dictionary native configuration, as code rather than prose.
2
+ //
3
+ // The stock ios-swift and compose transform groups emit every px-authored
4
+ // dimension at x16 its value, in Swift and Kotlin that compile. This module is
5
+ // the verified replacement. Zero dependencies: Style Dictionary is passed in,
6
+ // never imported, so this file installs into a user's packages/tokens/scripts/
7
+ // alongside lib/dtcg.mjs.
8
+ //
9
+ // references/native-adapter-config.md is GENERATED from this file by
10
+ // scripts/build-native-adapter-config.mjs. Edit the code here, then regenerate.
11
+ // @doc-section imports
12
+ import { readFileSync } from 'node:fs';
13
+ import { flattenDtcg, resolveValue, findModeCollisions } from './dtcg.mjs';
14
+ import { isValidLiteral, GRAMMAR, CSS_CONSTRUCT } from './native-literal.mjs';
15
+ // @doc-section-end imports
16
+
17
+ // @doc-section unit-aware
18
+ // Read the magnitude from the AUTHORED value's own unit.
19
+ //
20
+ // The stock size/swift/remToCGFloat and size/compose/rem* transforms assume rem
21
+ // and multiply by 16. Against a px-authored source that emits text.sm: "14px"
22
+ // as CGFloat(224.00) — valid, compiling, and sixteen times too large.
23
+ //
24
+ // iOS points and Android dp both map 1:1 to CSS px by convention. A unitless
25
+ // dimension is a ratio and is never scaled. % and em are container- or
26
+ // parent-relative, so there is genuinely no build-time native magnitude.
27
+ export function magnitude(authored) {
28
+ const m = String(authored).trim().match(/^(-?(?:\d+(?:\.\d+)?|\.\d+))([a-z%]*)$/);
29
+ if (!m) return null;
30
+ const n = Number(m[1]);
31
+ if (m[2] === 'px' || m[2] === '') return n;
32
+ if (m[2] === 'rem') return n * 16;
33
+ return null;
34
+ }
35
+ // @doc-section-end unit-aware
36
+
37
+ // @doc-section color-mix
38
+ // Compute a color-mix() against transparent to a literal hex8.
39
+ //
40
+ // A CSS expression has no native equivalent and Style Dictionary does no colour
41
+ // math, so it resolves the inner reference and leaves the function wrapper in
42
+ // the output. Against `transparent` in srgb the result is the inner colour at
43
+ // the stated alpha.
44
+ const MIX = /^color-mix\(in srgb,\s*(#[0-9a-fA-F]{6})\s+([\d.]+)%,\s*transparent\)$/;
45
+
46
+ export function colorMixToHex8(value) {
47
+ const m = String(value).trim().match(MIX);
48
+ if (!m) return null;
49
+ const alpha = Math.round((Number(m[2]) / 100) * 255)
50
+ .toString(16)
51
+ .padStart(2, '0');
52
+ return `${m[1]}${alpha}`.toLowerCase();
53
+ }
54
+ // @doc-section-end color-mix
55
+
56
+ // @doc-section preprocess
57
+ // Resolve aliases and hoist dual-node children, before Style Dictionary sees
58
+ // the tree.
59
+ //
60
+ // Two distinct SD limitations, both caused by a node carrying BOTH a $value and
61
+ // children — invalid DTCG: the Format Module's 30 July 2026 draft, §6.1,
62
+ // requires tools to report this as an error; §6.2's $root is the sanctioned
63
+ // way to pair a value with children. Common in Figma-derived sources anyway,
64
+ // where text.sm holds $value "14px" plus a text.sm.lineHeight child:
65
+ //
66
+ // 1. The resolver will not traverse into such a node, so every alias to the
67
+ // child fails to resolve and emits as a bare literal.
68
+ // 2. The collector also stops there, so the child is never emitted at all.
69
+ //
70
+ // Resolving here also handles references embedded inside an expression, which
71
+ // SD's whole-value matcher misses. Pre-resolving costs nothing on native
72
+ // targets: they set outputReferences: false, so references flatten regardless.
73
+ //
74
+ // Marks a node whose AUTHORED $value was a whole-value reference, so the hoist
75
+ // can decline to override the type DTCG 5.2.2 rule 1 already determined from the
76
+ // referent. A WeakSet keyed on the node object, rather than a property written
77
+ // onto it, holds structural idempotency exactly: structuredClone drops the
78
+ // membership along with the rest of the identity, so preprocess(preprocess(x))
79
+ // is deepEqual to preprocess(x) with no leak question to manage.
80
+ const WAS_REF = new WeakSet();
81
+ const WHOLE_REF = /^\{[^}]+\}$/;
82
+
83
+ function interpolate(value, flat) {
84
+ return value.replace(/\{([^}]+)\}/g, (whole, ref) => {
85
+ try {
86
+ return String(resolveValue(ref, flat));
87
+ } catch {
88
+ return whole;
89
+ }
90
+ });
91
+ }
92
+
93
+ function resolveInPlace(node, flat, prefix = []) {
94
+ for (const [key, val] of Object.entries(node)) {
95
+ if (key.startsWith('$')) continue;
96
+ if (!val || typeof val !== 'object') continue;
97
+ const path = [...prefix, key];
98
+ if ('$value' in val && typeof val.$value === 'string') {
99
+ if (WHOLE_REF.test(val.$value)) {
100
+ WAS_REF.add(val);
101
+ try {
102
+ val.$value = resolveValue(path.join('.'), flat);
103
+ } catch {
104
+ /* leave an unresolvable reference in place for SD to report */
105
+ }
106
+ } else {
107
+ val.$value = interpolate(val.$value, flat);
108
+ }
109
+ }
110
+ resolveInPlace(val, flat, path);
111
+ }
112
+ return node;
113
+ }
114
+
115
+ // text.sm.lineHeight becomes text.smLineHeight, which name/camel renders as
116
+ // textSmLineHeight — the identical symbol the un-hoisted path would produce.
117
+ //
118
+ // Collisions are COLLECTED, not thrown here: the walk has to continue to report
119
+ // every one, and the recursion is depth-first, so throwing from a frame would
120
+ // report one subtree. preprocess throws once, after the whole tree is walked.
121
+ //
122
+ // On collision the assignment is SKIPPED. Continuing to overwrite while
123
+ // collecting means later detections are computed against a tree already
124
+ // corrupted — the enclosing loop's Object.entries snapshot still holds the
125
+ // detached node, and its own children then hoist out of a subtree no longer
126
+ // reachable.
127
+ //
128
+ // hoisted in node walks the prototype chain, so a camel-joined name matching
129
+ // an inherited Object.prototype member (toString, valueOf, ...) reported a
130
+ // collision against a sibling that does not exist. Object.hasOwn checks the
131
+ // tree's own keys only.
132
+ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
133
+ // The $type a plain member of THIS frame inherits, and the one a child
134
+ // hoisted INTO it will inherit — the same value, because the hoist makes the
135
+ // child a member of node. A node carrying a $value is a token, not a group
136
+ // (DTCG 6.1), so it is not an inheritance source: look through it and keep
137
+ // the chain from above. That also covers the nested case, where node is a
138
+ // dual node and the child hoists past it on the next frame up anyway.
139
+ const inherited = '$value' in node ? groupType : (node.$type ?? groupType);
140
+ // Which hoisted names THIS pass has already claimed, and the authored path
141
+ // of the child that claimed each one — a collision here is a second hoist
142
+ // landing on a name no sibling ever authored. Local to this frame: node is
143
+ // fixed per invocation, so collisions are always within one parent's key
144
+ // space.
145
+ const claimedBy = new Map();
146
+ for (const [key, val] of Object.entries(node)) {
147
+ if (key.startsWith('$') || !val || typeof val !== 'object') continue;
148
+ hoistDualNodes(val, collisions, [...prefix, key], inherited);
149
+ if ('$value' in val) {
150
+ for (const [childKey, childVal] of Object.entries(val)) {
151
+ if (childKey.startsWith('$') || !childVal || typeof childVal !== 'object') continue;
152
+ const hoisted = key + childKey[0].toUpperCase() + childKey.slice(1);
153
+ const from = [...prefix, key, childKey].join('.');
154
+ if (Object.hasOwn(node, hoisted)) {
155
+ const existingNode = node[hoisted];
156
+ const isGroup = existingNode !== null && typeof existingNode === 'object' && !('$value' in existingNode);
157
+ collisions.push({
158
+ from,
159
+ onto: [...prefix, hoisted].join('.'),
160
+ isGroup,
161
+ claimant: claimedBy.get(hoisted),
162
+ existing: isGroup
163
+ ? undefined
164
+ : existingNode && typeof existingNode === 'object'
165
+ ? existingNode.$value
166
+ : existingNode,
167
+ });
168
+ continue;
169
+ }
170
+ // The carry pays for what the hoist costs: the dual node is the
171
+ // child's closest $type-bearing ancestor as authored, and after the
172
+ // hoist it is a sibling, so that type is lost unless it travels.
173
+ //
174
+ // Two cases where nothing was lost, so nothing is carried. A
175
+ // reference-valued child already has its referent's resolved type, and
176
+ // DTCG 5.2.2 rule 1 ranks that above inheritance. And where an
177
+ // enclosing GROUP supplies a type, that group was the child's
178
+ // inheritance source all along — 5.2.2 inherits from the closest parent
179
+ // group, and the dual node is a token — so it still is after the hoist,
180
+ // and carrying would shadow it.
181
+ //
182
+ // The invariant, stated no wider than it holds: the hoist never
183
+ // CHANGES a type DTCG inheritance already determines. Where it
184
+ // determines none, the carry supplies the dual node's — a repair, not
185
+ // a reading of the source. So an enclosing group wins even where its
186
+ // type suits the child badly, because the hoist is not entitled to
187
+ // improve on what the source says; but the carry firing at all is the
188
+ // hoist saying something the source did not.
189
+ if (
190
+ !('$type' in childVal) &&
191
+ '$type' in val &&
192
+ !WAS_REF.has(childVal) &&
193
+ inherited === undefined
194
+ ) {
195
+ childVal.$type = val.$type;
196
+ }
197
+ node[hoisted] = childVal;
198
+ delete val[childKey];
199
+ claimedBy.set(hoisted, from);
200
+ }
201
+ }
202
+ }
203
+ return node;
204
+ }
205
+
206
+ // Which native unit a dimension belongs in: Compose's Dp, or its TextUnit.
207
+ //
208
+ // $type cannot answer this. DTCG's type set has no fontSize — font sizes are
209
+ // dimension, and so are spacing, radius and stroke widths — so the stock
210
+ // size/compose/remToSp filter on $type === 'fontSize' never fires on a
211
+ // spec-compliant source and every font size falls through to dp.
212
+ //
213
+ // The role therefore comes from the one place a DTCG source states it: the
214
+ // member names the Format Module's 30 July 2026 draft, §9.8, fixes at MUST
215
+ // level for the typography composite. Two of the five are dimension-valued:
216
+ // fontSize and letterSpacing. §9.8 types lineHeight as a NUMBER multiplier, so
217
+ // a source following the spec exactly emits no dimension-typed lineHeight and
218
+ // this rule never fires on one. Figma-derived sources — what this module
219
+ // targets — emit px line heights typed dimension, and those are the majority of
220
+ // the tokens the rule fixes on a real source. lineHeight is named here anyway
221
+ // because Compose's TextStyle takes TextUnit for all three, with no Dp
222
+ // overload, so a px line height must reach the sp branch to be usable at all.
223
+ //
224
+ // The limit, stated rather than hidden: §9.8 puts those names inside a
225
+ // composite token's $value object, while Figma-derived sources put them as
226
+ // sibling tokens in a group. Reading them there mirrors the spec's vocabulary;
227
+ // it is not a guarantee the spec makes. A source naming its font size
228
+ // typography.body.size sets $extensions itself and is honoured below.
229
+ const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight']);
230
+
231
+ // Reverse-DNS, per DTCG's $extensions convention. Exported because the
232
+ // transforms and their tests address the same key.
233
+ export const EXT_NS = 'com.radicool.throughline';
234
+
235
+ // px and rem only. magnitude() reads a bare number as an unscaled ratio, so a
236
+ // lineHeight authored "1.5" would otherwise be stamped and emit 1.50.sp —
237
+ // which compiles and renders 1.5sp text, trading a loud failure for a silent
238
+ // one. Since #52 a unitless value is declined by every size transform and
239
+ // emits bare, which is what DTCG 8.7 and 9.8 say a ratio is, so this gate is
240
+ // no longer the only thing standing between a ratio and 1.50.sp. It stays
241
+ // because the stamp is also the override's carrier, and stamping a ratio as
242
+ // text would still be a claim the source never made.
243
+ // em joins px and rem since #64: Compose's TextUnit has a real .em, so an
244
+ // em-valued letterSpacing is a text-role dimension like any other. It is still
245
+ // a unit — the gate's job is to exclude the UNITLESS value, whose role the
246
+ // source never stated.
247
+ const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
248
+
249
+ // Runs AFTER resolveInPlace and BEFORE hoistDualNodes. Both halves matter.
250
+ //
251
+ // After resolution, because the unit is not in the authored text: a semantic
252
+ // font size is authored "{text.3xl}" and carries no unit at all. Reading the
253
+ // authored string would classify only the px-authored primitives — 13 of 39
254
+ // on a real source.
255
+ //
256
+ // Before the hoist, because the hoist consumes the leaf name:
257
+ // text.xs.lineHeight becomes text.xsLineHeight, and the name this rule matches
258
+ // on is gone. Matching a suffix against the camel-joined name instead would
259
+ // couple the rule to the hoist's naming scheme, and case-insensitively it
260
+ // false-positives on names like baselineHeight.
261
+ function classifyTextUnits(node) {
262
+ for (const [key, val] of Object.entries(node)) {
263
+ if (key.startsWith('$') || !val || typeof val !== 'object') continue;
264
+ if (
265
+ TEXT_UNIT_NAMES.has(key) &&
266
+ '$value' in val &&
267
+ val.$type === 'dimension' &&
268
+ TEXT_ROLE_UNIT.test(String(val.$value).trim())
269
+ ) {
270
+ val.$extensions ??= {};
271
+ val.$extensions[EXT_NS] ??= {};
272
+ const ns = val.$extensions[EXT_NS];
273
+ // A source that states the role itself wins — for a value that HAS a
274
+ // unit. The override chooses between dp and sp; it does not manufacture
275
+ // one, so a unitless value is declined by every size transform regardless
276
+ // of what is stamped here (see isRatio, #52). Declining to overwrite IS
277
+ // the feature: it costs no configuration parameter, and it is what makes
278
+ // the pass idempotent.
279
+ if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
280
+ }
281
+ classifyTextUnits(val);
282
+ }
283
+ return node;
284
+ }
285
+
286
+ export function preprocess(dict) {
287
+ const collisions = [];
288
+ const out = hoistDualNodes(
289
+ classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
290
+ collisions,
291
+ );
292
+ if (collisions.length) {
293
+ const shown = collisions
294
+ .slice(0, 5)
295
+ .map((c) => {
296
+ const line = ` ${c.from} -> ${c.onto}`;
297
+ if (c.isGroup) {
298
+ return line + (c.claimant ? ` (a group, already claimed by the hoist of ${c.claimant})` : ' (a group)');
299
+ }
300
+ return c.claimant
301
+ ? `${line} (already claimed by the hoist of ${c.claimant}, value ${JSON.stringify(c.existing)})`
302
+ : `${line} (would overwrite ${JSON.stringify(c.existing)})`;
303
+ })
304
+ .join('\n');
305
+ const more = collisions.length > 5 ? `\n ...and ${collisions.length - 5} more` : '';
306
+ throw new Error(
307
+ `${collisions.length} hoisted token name(s) collide with an existing sibling or with a name an earlier hoist already claimed.\n` +
308
+ "A dual node's child is renamed to a camel-joined sibling, and that name may already be taken — by an authored token or group, or by another dual node's child hoisted earlier in the same pass.\n" +
309
+ 'Hoisting would silently discard one of the two. Rename the child, the sibling, or whichever colliding child should keep the name.\n' +
310
+ `${shown}${more}`,
311
+ );
312
+ }
313
+ return out;
314
+ }
315
+ // @doc-section-end preprocess
316
+
317
+ // @doc-section platform
318
+ // Build each platform's transform list from Style Dictionary's STOCK group,
319
+ // replacing only the rem-assuming size transforms and inserting the color-mix
320
+ // computation ahead of the colour transform. A hand-picked list silently drops
321
+ // whatever it forgets; three real defects arose that way, including Compose
322
+ // font sizes rendered in dp instead of sp.
323
+ //
324
+ // That last one is fixed for tokens whose role a DTCG source actually states:
325
+ // classifyTextUnits stamps fontSize, letterSpacing and lineHeight members, and
326
+ // the sp transform gates on the stamp rather than on a $type DTCG never emits.
327
+ // Two limits remain, both Android-only and both measured rather than
328
+ // theoretical — see docs/superpowers/notes/2026-08-21-native-config-e2e-results.md:
329
+ //
330
+ // - A scale primitive carries no role. text.base: "16px" is a font size only
331
+ // to a human, so it emits as dp. The semantic tokens referencing it are
332
+ // correct, and those are what a consumer should reach for.
333
+ // - An em-valued letterSpacing is filtered out of native output entirely,
334
+ // rather than emitted as Compose's .em TextUnit.
335
+ //
336
+ // The third — a unitless ratio emitting as dp — is fixed by #52: no size
337
+ // transform claims a unitless value, so it emits bare on both platforms, and
338
+ // tokens:validate-output reports it as a unitless-dimension advisory.
339
+ //
340
+ // The lists below mirror Style Dictionary's stock groups, and nothing derives
341
+ // them at runtime — what runs stays deliberate and reviewable. But nothing is
342
+ // transcribed either: auditStockGroups checks at registration that every name
343
+ // in the live stock group is either run here or declined in writing, so a
344
+ // stock transform this config has never made a decision about is loud rather
345
+ // than silently dropped.
346
+ //
347
+ // Both groups were verified byte-identical in SD 4.4.0 and 5.5.2. The `ios`
348
+ // group was not — it renamed size/remToPt to size/remToFloat between them, and
349
+ // 5.x added seven transforms overall. The drift this guards against is real;
350
+ // it has simply not landed on the two groups we build from.
351
+ const PLATFORMS = {
352
+ 'ios-swift': {
353
+ stockGroup: 'ios-swift',
354
+ transforms: [
355
+ 'attribute/cti',
356
+ 'name/camel',
357
+ 'value/color-mix-to-hex8',
358
+ 'color/UIColorSwift',
359
+ 'content/swift/literal',
360
+ 'asset/swift/literal',
361
+ 'size/unit-aware/swift',
362
+ 'value/swift-string-literal',
363
+ ],
364
+ destination: 'Tokens.swift',
365
+ format: 'ios-swift/enum.swift',
366
+ },
367
+ 'android-kotlin': {
368
+ stockGroup: 'compose',
369
+ transforms: [
370
+ 'attribute/cti',
371
+ 'name/camel',
372
+ 'value/color-mix-to-hex8',
373
+ 'color/composeColor',
374
+ 'size/unit-aware/compose-dp',
375
+ 'size/unit-aware/compose-sp',
376
+ 'size/unit-aware/compose-em',
377
+ 'value/kotlin-string-literal',
378
+ ],
379
+ destination: 'Tokens.kt',
380
+ format: 'compose/object',
381
+ },
382
+ };
383
+
384
+ // Stock transforms this config deliberately does NOT run. The reason is the
385
+ // point: an entry here is a decision on the record, where an absence from
386
+ // PLATFORMS is indistinguishable from an oversight.
387
+ //
388
+ // Keyed by transform name alone, with no platform qualifier. That is safe only
389
+ // because every name here is platform-prefixed, so no cross-platform collision
390
+ // is expressible. Declining an unprefixed name — a hypothetical shared
391
+ // "size/px" — would widen silently across both platforms and must convert this
392
+ // to a per-platform map.
393
+ const DECLINED_STOCK_TRANSFORMS = {
394
+ 'size/swift/remToCGFloat': 'rem-assuming — replaced by size/unit-aware/swift',
395
+ 'size/compose/remToDp': 'rem-assuming — replaced by size/unit-aware/compose-dp',
396
+ 'size/compose/remToSp': 'rem-assuming — replaced by size/unit-aware/compose-sp',
397
+ 'size/compose/em': 'rem-assuming — replaced by size/unit-aware/compose-em',
398
+ };
399
+
400
+ // Report every transform in a platform's live stock group that this config
401
+ // neither runs nor explicitly declined. Pure: it takes Style Dictionary's
402
+ // hooks.transformGroups and returns formatted warning strings, so the wording
403
+ // is what the tests assert and the caller is a bare loop.
404
+ //
405
+ // Warns, never throws. The dangerous direction is an ADDITION we never learned
406
+ // about, which is usually harmless and occasionally important — throwing would
407
+ // break a build over a change the consumer cannot fix. The fatal direction, a
408
+ // transform we run being removed, already makes Style Dictionary throw on an
409
+ // unknown transform name.
410
+ //
411
+ // Order is never compared: our lists are hand-ordered for our own reasons and
412
+ // do not inherit stock order. Removals are never reported: a declined name
413
+ // disappearing is a non-event.
414
+ export function auditStockGroups(transformGroups) {
415
+ if (typeof transformGroups !== 'object' || transformGroups === null) {
416
+ return [
417
+ "throughline: could not read Style Dictionary's stock transform groups " +
418
+ '(hooks.transformGroups is not an object), so this adapter cannot check ' +
419
+ 'whether its transform lists are still complete.',
420
+ ];
421
+ }
422
+ const warnings = [];
423
+ for (const [platform, preset] of Object.entries(PLATFORMS)) {
424
+ const group = preset.stockGroup;
425
+ if (!group || !Array.isArray(preset.transforms)) {
426
+ warnings.push(
427
+ `throughline: PLATFORMS['${platform}'] is incomplete — it needs both ` +
428
+ 'stockGroup and transforms — so its transform list cannot be checked ' +
429
+ "against Style Dictionary's stock groups. This is a throughline " +
430
+ 'packaging defect — please report it.',
431
+ );
432
+ continue;
433
+ }
434
+ const stock = transformGroups[group];
435
+ if (!Array.isArray(stock)) {
436
+ warnings.push(
437
+ `throughline: Style Dictionary has no "${group}" transform group, which ` +
438
+ `PLATFORMS['${platform}'] mirrors. The stock group may have been ` +
439
+ 'renamed or removed. Upgrade @radicool/throughline, or report your ' +
440
+ 'Style Dictionary version.',
441
+ );
442
+ continue;
443
+ }
444
+ const unaccounted = stock.filter(
445
+ (name) =>
446
+ !preset.transforms.includes(name) &&
447
+ !Object.hasOwn(DECLINED_STOCK_TRANSFORMS, name),
448
+ );
449
+ if (unaccounted.length) {
450
+ const n = unaccounted.length;
451
+ warnings.push(
452
+ `throughline: Style Dictionary's "${group}" transform group has ${n} ` +
453
+ `transform${n === 1 ? '' : 's'} this adapter neither runs nor declined: ` +
454
+ `${unaccounted.join(', ')}. Native output may be incomplete. Upgrade ` +
455
+ '@radicool/throughline, or report your Style Dictionary version. ' +
456
+ `(Maintainer repair: add each to PLATFORMS['${platform}'].transforms, ` +
457
+ 'or to DECLINED_STOCK_TRANSFORMS with a reason.)',
458
+ );
459
+ }
460
+ }
461
+ return warnings;
462
+ }
463
+
464
+ // % and em are container- or parent-relative, so there is no build-time native
465
+ // magnitude. Filter on the AUTHORED value, not on $type — a "100%" token may be
466
+ // typed string rather than dimension.
467
+ const WEB_ONLY_UNIT = /^-?[\d.]+(%|em)$/;
468
+
469
+ // The em magnitude, which magnitude() deliberately does not return: em has no
470
+ // build-time px equivalent, so it is not a native LENGTH. It is a TextUnit.
471
+ const EM_VALUE = /^(-?(?:\d+(?:\.\d+)?|\.\d+))em$/;
472
+
473
+ export function nativeFilter(token, platform) {
474
+ const v = String(token.original?.$value ?? token.$value).trim();
475
+ if (!WEB_ONLY_UNIT.test(v)) return true;
476
+ // One exception, and it is narrow. Compose has a real .em TextUnit, so an
477
+ // em letterSpacing DOES have a native form there — unlike %, which has none
478
+ // anywhere. It survives only where all three hold: the platform is Compose,
479
+ // the value is em, and the token carries the text role. An em SPACING has no
480
+ // TextUnit meaning and still drops.
481
+ //
482
+ // iOS is deliberately excluded rather than pending. Letter spacing there is
483
+ // an NSAttributedString kern in points, which needs the font size the token
484
+ // does not carry, so there is no value Swift could emit that would not be
485
+ // wrong at some font size.
486
+ return platform === 'android-kotlin' && EM_VALUE.test(v) && isTextUnit(token);
487
+ }
488
+
489
+ // A CSS function has no native form. Quoting it would produce a string that
490
+ // compiles and means nothing — the exact failure class this module exists to
491
+ // prevent, and worse than the bare value, which at least fails to compile.
492
+ // Leave it bare so the filter drops it.
493
+ //
494
+ // No \s* before the paren: CSS function notation forbids whitespace between
495
+ // the name and the open paren, and a real font family can legitimately
496
+ // contain one — "Helvetica (Regular)". Requiring the paren immediately after
497
+ // the identifier is what tells that apart from linear-gradient(, calc(,
498
+ // var(, and color-mix(.
499
+ const CSS_FUNCTION = /^[A-Za-z][A-Za-z0-9-]*\(/;
500
+
501
+ // Did the transforms leave a value with no native form at all?
502
+ //
503
+ // A different question from nativeFilter's, which is about the AUTHORED
504
+ // value. This reads the TRANSFORMED $value. A value that already parses as a
505
+ // literal passes outright. A value that does not is dropped only if it is
506
+ // ALSO shaped like a CSS function call — a linear-gradient, say, which has no
507
+ // native rendering whatsoever. Everything else invalid but not function-shaped
508
+ // stays and fails loudly at compile time: duration ("200ms"), cubicBezier
509
+ // ("0.5,0,1,1"), and, on Kotlin, content and asset, which have no stock
510
+ // quoting transform there. Silently dropping those would hide a forgotten
511
+ // $type behind a shorter output file instead of a build failure.
512
+ //
513
+ // A CSS_CONSTRUCT match is exempt from the drop even though it fails
514
+ // isValidLiteral: calc(...) and var(...) are unrescued but valid identifiers,
515
+ // and an unrescued color-mix(...) variant is a rescue this module's own
516
+ // color-mix transform simply did not match — none of those are "no native
517
+ // form", they are unimplemented rescues. Dropping them here would make
518
+ // no-foreign-syntax in validate-token-output.mjs unreachable, so they are
519
+ // kept and left to fail loudly there instead.
520
+ export function hasNativeForm(token, platform) {
521
+ const grammar = GRAMMAR[platform];
522
+ if (!grammar) {
523
+ throw new Error(`unknown native platform "${platform}" (expected ${Object.keys(GRAMMAR).join(' or ')})`);
524
+ }
525
+ const v = String(token.$value).trim();
526
+ return isValidLiteral(v, grammar) || CSS_CONSTRUCT.test(v) || !CSS_FUNCTION.test(v);
527
+ }
528
+
529
+ export function nativePlatform({ platform, buildPath, className = 'Tokens', packageName }) {
530
+ const preset = PLATFORMS[platform];
531
+ if (!preset) {
532
+ throw new Error(
533
+ `unknown native platform "${platform}" (expected ${Object.keys(PLATFORMS).join(' or ')})`,
534
+ );
535
+ }
536
+ if (platform === 'android-kotlin' && !packageName) {
537
+ throw new Error(
538
+ 'android-kotlin requires a packageName: the compose/object template emits ' +
539
+ '`package ${packageName ?? ""}`, so omitting it produces a bare "package " ' +
540
+ 'line, which is not valid Kotlin',
541
+ );
542
+ }
543
+ const fileOptions = platform === 'android-kotlin' ? { className, packageName } : { className };
544
+ return {
545
+ transforms: [...preset.transforms],
546
+ // Carried here, not left to the caller: authored() reads the ORIGINAL
547
+ // $value, so without this preprocessor every aliased dimension still holds
548
+ // an unresolved {spacing.space.4}, no size transform fires, and the build
549
+ // emits bare px literals. preprocess is idempotent, so a project that also
550
+ // declares it at top level is harmless.
551
+ preprocessors: ['dtcg/resolve-dual-node'],
552
+ buildPath,
553
+ options: { outputReferences: false },
554
+ files: [
555
+ {
556
+ destination: preset.destination,
557
+ format: preset.format,
558
+ options: fileOptions,
559
+ filter: (token) => nativeFilter(token, platform) && hasNativeForm(token, platform),
560
+ },
561
+ ],
562
+ };
563
+ }
564
+ // @doc-section-end platform
565
+
566
+ // @doc-section sources
567
+ // Guard the source list for ONE mode, and return it so it can only be used
568
+ // through this call.
569
+ //
570
+ // Style Dictionary deduplicates by dot-path, so a build whose sources contain
571
+ // both a light and a dark definition of the same token keeps whichever file
572
+ // sorts last and drops the other mode with no diagnostic. Wrapping the value
573
+ // the build already needs makes the check unskippable: omitting it means
574
+ // deleting a call whose return value is consumed.
575
+ //
576
+ // source: nativeSources(sourcesForThisMode)
577
+ //
578
+ // An unexpanded glob is the failure that actually lands here, and a raw ENOENT
579
+ // on the literal string "tokens/*.json" reads as a crash rather than a
580
+ // diagnosis. Name the path and what was expected.
581
+ const EXPECTED = 'nativeSources takes explicit file paths for ONE mode — never a glob, never a directory.';
582
+
583
+ function readTokenFile(file) {
584
+ let raw;
585
+ try {
586
+ raw = readFileSync(file, 'utf8');
587
+ } catch (err) {
588
+ throw new Error(`cannot read token source "${file}": ${err.message}\n${EXPECTED}`);
589
+ }
590
+ try {
591
+ return JSON.parse(raw);
592
+ } catch (err) {
593
+ throw new Error(`token source "${file}" is not valid JSON: ${err.message}\n${EXPECTED}`);
594
+ }
595
+ }
596
+
597
+ export function nativeSources(paths) {
598
+ const parsed = paths.map((file) => ({ file, dtcg: readTokenFile(file) }));
599
+ const collisions = findModeCollisions(parsed);
600
+ if (collisions.length === 0) return paths;
601
+
602
+ const shown = collisions
603
+ .slice(0, 5)
604
+ .map((c) => ` ${c.path}: ${c.defs.map((d) => d.file).join(' vs ')}`)
605
+ .join('\n');
606
+ const more = collisions.length > 5 ? `\n ...and ${collisions.length - 5} more` : '';
607
+ throw new Error(
608
+ `${collisions.length} token path(s) are defined differently across this build's sources.\n` +
609
+ 'Style Dictionary keeps whichever file sorts last, silently dropping a whole mode.\n' +
610
+ 'Build once per mode, passing an explicit source list for that mode only.\n' +
611
+ `${shown}${more}`,
612
+ );
613
+ }
614
+ // @doc-section-end sources
615
+
616
+ // @doc-section register
617
+ // Register everything with a Style Dictionary instance. SD is a parameter, not
618
+ // an import, so this module stays zero-dependency and installable.
619
+ const authored = (token) => magnitude(token.original?.$value ?? token.$value);
620
+ const isDimension = (token) => token.$type === 'dimension';
621
+ const isFontSize = (token) => token.$type === 'fontSize';
622
+ const hasMagnitude = (token) => authored(token) !== null;
623
+ // A unitless value is a ratio, not a measurement. DTCG 8.2.1 requires a
624
+ // dimension to carry a unit ("still required even if $value.value is 0"), 8.7's
625
+ // `number` is the type for a multiplier, and 9.8 types lineHeight as one — so a
626
+ // unitless dimension is malformed input, and appending dp/sp/CGFloat to it
627
+ // invents a unit the source never stated. Declining it emits the raw value,
628
+ // which is exactly what a correctly typed `number` already produces.
629
+ //
630
+ // Reads the ORIGINAL authored value, like authored(), and must: preprocess has
631
+ // already resolved references by transform time, and a value transform earlier
632
+ // in the chain may have rewritten $value.
633
+ const RATIO = /^-?(?:\d+(?:\.\d+)?|\.\d+)$/;
634
+ const isRatio = (token) => RATIO.test(String(token.original?.$value ?? token.$value).trim());
635
+ // The role preprocess stamped. $type cannot carry it — see classifyTextUnits.
636
+ const isTextUnit = (token) => token.$extensions?.[EXT_NS]?.nativeUnit === 'text';
637
+ const emMagnitude = (token) => {
638
+ const m = String(token.original?.$value ?? token.$value)
639
+ .trim()
640
+ .match(EM_VALUE);
641
+ return m ? Number(m[1]) : null;
642
+ };
643
+
644
+ // Quote string-valued tokens no stock transform covers.
645
+ //
646
+ // Style Dictionary quotes by $type: content/swift/literal and
647
+ // asset/swift/literal handle $type content and asset. A $type: fontFamily token
648
+ // matches neither and emits bare — `public static let f = Nunito Sans`, which
649
+ // is not Swift. There is no stock transform for it.
650
+ const QUOTED_TYPES = new Set(['fontFamily', 'string']);
651
+
652
+ // A DTCG fontFamily may be a list; join it into one native string.
653
+ function stringValue(token) {
654
+ const v = Array.isArray(token.$value) ? token.$value.join(', ') : token.$value;
655
+ return typeof v === 'string' ? v : null;
656
+ }
657
+
658
+ // DTCG permits fontWeight as a keyword ("bold") as well as a number. The
659
+ // keyword form emits as a bare identifier and hits the identical failure;
660
+ // "400" already emits as a valid native integer and must stay untouched.
661
+ function isQuotable(token) {
662
+ const v = stringValue(token);
663
+ if (v === null) return false;
664
+ if (CSS_FUNCTION.test(v)) return false;
665
+ if (QUOTED_TYPES.has(token.$type)) return true;
666
+ return token.$type === 'fontWeight' && Number.isNaN(Number(v.trim()));
667
+ }
668
+
669
+ const escapeCommon = (s) =>
670
+ s
671
+ .replace(/\\/g, '\\\\')
672
+ .replace(/"/g, '\\"')
673
+ .replace(/\n/g, '\\n')
674
+ .replace(/\r/g, '\\r')
675
+ .replace(/\t/g, '\\t');
676
+
677
+ export function registerNativeTransforms(StyleDictionary) {
678
+ StyleDictionary.registerPreprocessor({
679
+ name: 'dtcg/resolve-dual-node',
680
+ preprocessor: preprocess,
681
+ });
682
+
683
+ StyleDictionary.registerTransform({
684
+ name: 'value/color-mix-to-hex8',
685
+ type: 'value',
686
+ transitive: true,
687
+ filter: (token) => colorMixToHex8(token.$value) !== null,
688
+ transform: (token) => colorMixToHex8(token.$value),
689
+ });
690
+
691
+ // Stock size/swift/remToCGFloat filters dimension OR fontSize; match it.
692
+ StyleDictionary.registerTransform({
693
+ name: 'size/unit-aware/swift',
694
+ type: 'value',
695
+ transitive: true,
696
+ filter: (token) => (isDimension(token) || isFontSize(token)) && hasMagnitude(token) && !isRatio(token),
697
+ transform: (token) => `CGFloat(${authored(token).toFixed(2)})`,
698
+ });
699
+
700
+ // sp is what respects the user's font-scale accessibility setting, and
701
+ // Compose's TextStyle takes TextUnit — not Dp — for fontSize, lineHeight and
702
+ // letterSpacing, so a Dp there does not even compile at the use site. One .dp
703
+ // transform for both would silently defeat the first and loudly break the
704
+ // second. The split is driven by the role classifyTextUnits stamped, plus
705
+ // Style Dictionary's own $type: fontSize convention for sources that use it.
706
+ StyleDictionary.registerTransform({
707
+ name: 'size/unit-aware/compose-dp',
708
+ type: 'value',
709
+ transitive: true,
710
+ filter: (token) => isDimension(token) && !isTextUnit(token) && hasMagnitude(token) && !isRatio(token),
711
+ transform: (token) => `${authored(token).toFixed(2)}.dp`,
712
+ });
713
+
714
+ StyleDictionary.registerTransform({
715
+ name: 'size/unit-aware/compose-sp',
716
+ type: 'value',
717
+ transitive: true,
718
+ filter: (token) => (isTextUnit(token) || isFontSize(token)) && hasMagnitude(token) && !isRatio(token),
719
+ transform: (token) => `${authored(token).toFixed(2)}.sp`,
720
+ });
721
+
722
+ // em is a THIRD text unit, not a variant of sp. Compose's .em is relative to
723
+ // the font size at the use site, which is what an em letterSpacing means, so
724
+ // it needs neither a magnitude nor a conversion. dp and sp both decline these
725
+ // already — magnitude() returns null for em — so nothing contends.
726
+ //
727
+ // The parentheses are load-bearing. `-0.03.em` parses as `-(0.03.em)`, which
728
+ // kotlinc 2.4.10 rejects with "unresolved reference 'unaryMinus'" unless
729
+ // TextUnit defines that operator. `(-0.03).em` compiles either way, and
730
+ // negatives are the common case: a tight letterSpacing is negative.
731
+ StyleDictionary.registerTransform({
732
+ name: 'size/unit-aware/compose-em',
733
+ type: 'value',
734
+ transitive: true,
735
+ filter: (token) => (isTextUnit(token) || isFontSize(token)) && emMagnitude(token) !== null,
736
+ transform: (token) => `(${emMagnitude(token).toFixed(2)}).em`,
737
+ });
738
+
739
+ // Two transforms rather than one platform-sniffing transform, because the
740
+ // escaping genuinely differs: "$foo" is template interpolation in Kotlin, so
741
+ // a literal $ must be escaped there and must NOT be in Swift, where \$ is not
742
+ // a valid escape at all.
743
+ StyleDictionary.registerTransform({
744
+ name: 'value/swift-string-literal',
745
+ type: 'value',
746
+ transitive: true,
747
+ filter: isQuotable,
748
+ transform: (token) => `"${escapeCommon(stringValue(token))}"`,
749
+ });
750
+
751
+ StyleDictionary.registerTransform({
752
+ name: 'value/kotlin-string-literal',
753
+ type: 'value',
754
+ transitive: true,
755
+ filter: isQuotable,
756
+ transform: (token) => `"${escapeCommon(stringValue(token)).replace(/\$/g, '\\$')}"`,
757
+ });
758
+
759
+ // Last, so every registration side effect has completed before anything is
760
+ // printed. Fires once per REGISTRATION — typically once per process, not once
761
+ // per build: the documented usage registers once and then constructs one
762
+ // StyleDictionary per mode, and the stock groups cannot change between modes.
763
+ //
764
+ // The ?. chain is what turns a caller with no hooks into undefined, which
765
+ // auditStockGroups reports as unreadable rather than silently skipping.
766
+ for (const warning of auditStockGroups(StyleDictionary?.hooks?.transformGroups)) {
767
+ console.warn(warning);
768
+ }
769
+ }
770
+ // @doc-section-end register