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