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