@radicool/throughline 0.16.0 → 0.18.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radicool/throughline",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Build a complete design system end to end — author in Figma, sync tokens to code, generate Storybook. Usable from Claude Code, Cursor, Codex, or any AGENTS.md agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,7 +52,16 @@ consumer's repo.
52
52
 
53
53
  ```js
54
54
  import { readFileSync } from 'node:fs';
55
- import { flattenDtcg, resolveValue, findModeCollisions } from './dtcg.mjs';
55
+ import {
56
+ flattenDtcg,
57
+ flattenDtcgTypes,
58
+ resolveValue,
59
+ findModeCollisions,
60
+ TEXT_UNIT_NAMES,
61
+ TEXT_ROLE_UNIT,
62
+ EXT_NS,
63
+ textRoleGraph,
64
+ } from './dtcg.mjs';
56
65
  import { isValidLiteral, GRAMMAR, CSS_CONSTRUCT } from './native-literal.mjs';
57
66
  ```
58
67
 
@@ -295,11 +304,16 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
295
304
  // sibling tokens in a group. Reading them there mirrors the spec's vocabulary;
296
305
  // it is not a guarantee the spec makes. A source naming its font size
297
306
  // typography.body.size sets $extensions itself and is honoured below.
298
- const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight']);
307
+ //
308
+ // Defined in lib/dtcg.mjs and imported above, so textRoleGraph applies the
309
+ // identical set. Two definitions of this set would drift.
299
310
 
300
311
  // Reverse-DNS, per DTCG's $extensions convention. Exported because the
301
312
  // transforms and their tests address the same key.
302
- export const EXT_NS = 'com.radicool.throughline';
313
+ //
314
+ // Defined in lib/dtcg.mjs and re-exported here: the transforms and their tests
315
+ // address this key through sd-native.mjs, and that surface does not move.
316
+ export { EXT_NS };
303
317
 
304
318
  // px and rem only. magnitude() reads a bare number as an unscaled ratio, so a
305
319
  // lineHeight authored "1.5" would otherwise be stamped and emit 1.50.sp —
@@ -313,7 +327,9 @@ export const EXT_NS = 'com.radicool.throughline';
313
327
  // em-valued letterSpacing is a text-role dimension like any other. It is still
314
328
  // a unit — the gate's job is to exclude the UNITLESS value, whose role the
315
329
  // source never stated.
316
- const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
330
+ //
331
+ // Defined in lib/dtcg.mjs and imported above, for the same reason as
332
+ // TEXT_UNIT_NAMES: textRoleGraph gates on it too.
317
333
 
318
334
  // Runs AFTER resolveInPlace and BEFORE hoistDualNodes. Both halves matter.
319
335
  //
@@ -327,13 +343,32 @@ const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
327
343
  // on is gone. Matching a suffix against the camel-joined name instead would
328
344
  // couple the rule to the hoist's naming scheme, and case-insensitively it
329
345
  // false-positives on names like baselineHeight.
330
- function classifyTextUnits(node) {
346
+ //
347
+ // The type comes from `types`, a DTCG 5.2.2 resolution of the whole tree, not
348
+ // from the token's own literal $type. Reading val.$type made this pass blind to
349
+ // a source that declares $type once on the group — legal DTCG, and on such a
350
+ // source the reference-graph inference stamped nothing at all (#85). A token's
351
+ // own $type still wins; the group's applies only where the token states none.
352
+ //
353
+ // WHERE THIS MATTERS, measured rather than assumed. Style Dictionary runs
354
+ // global preprocessors, THEN its own typeDtcgDelegate — which is 5.2.2, pushing
355
+ // each group's $type onto its descendants — then platform preprocessors
356
+ // (StyleDictionary.js:340, :348, :440 in 4.4.0). nativePlatform registers this
357
+ // preprocessor at PLATFORM level, downstream of that delegation, so a build
358
+ // wired only through nativePlatform never saw the defect: on a group-typed
359
+ // re-encoding of a real system it emits the same 208 declarations and 48 sp
360
+ // either way. The defect reaches the build that ALSO declares this preprocessor
361
+ // at top level, which is the wiring the usage snippet above shows — there the
362
+ // first pass runs before any delegation. Resolving the type here makes both
363
+ // wirings agree instead of depending on which one a consumer copied.
364
+ function classifyTextUnits(node, types, prefix = []) {
331
365
  for (const [key, val] of Object.entries(node)) {
332
366
  if (key.startsWith('$') || !val || typeof val !== 'object') continue;
367
+ const path = [...prefix, key];
333
368
  if (
334
369
  TEXT_UNIT_NAMES.has(key) &&
335
370
  '$value' in val &&
336
- val.$type === 'dimension' &&
371
+ types[path.join('.')] === 'dimension' &&
337
372
  TEXT_ROLE_UNIT.test(String(val.$value).trim())
338
373
  ) {
339
374
  val.$extensions ??= {};
@@ -347,15 +382,60 @@ function classifyTextUnits(node) {
347
382
  // the pass idempotent.
348
383
  if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
349
384
  }
350
- classifyTextUnits(val);
385
+ classifyTextUnits(val, types, path);
386
+ }
387
+ return node;
388
+ }
389
+
390
+ // Runs AFTER classifyTextUnits and BEFORE hoistDualNodes, on the SAME two
391
+ // grounds that pass gives: after, so a role the source or the member name
392
+ // already stated wins; before, because the hoist rewrites text.xs.lineHeight to
393
+ // text.xsLineHeight and the graph's paths are written in pre-hoist names.
394
+ //
395
+ // The three gates are classifyTextUnits's, verbatim — a dimension, a value with
396
+ // a unit, and no role already recorded. Both passes read the SAME resolved-type
397
+ // map, which is what makes them agree by construction rather than by two copies
398
+ // of DTCG 5.2.2 staying in step. A unitless value is never stamped: no
399
+ // size transform claims one since #52, and stamping a ratio as text would still
400
+ // be a claim the source never made.
401
+ //
402
+ // A path may name no node at all. resolveInPlace deliberately leaves an
403
+ // unresolvable reference in place for Style Dictionary to report, so the graph
404
+ // can hold an edge to a token that does not exist. Skip it. This is also what
405
+ // keeps the second preprocess pass from throwing, and idempotency with it.
406
+ function applyTextRoleGraph(node, typographic, types) {
407
+ for (const path of typographic) {
408
+ let target = node;
409
+ for (const segment of path.split('.')) {
410
+ target = target && typeof target === 'object' ? target[segment] : undefined;
411
+ }
412
+ if (!target || typeof target !== 'object' || !('$value' in target)) continue;
413
+ if (types[path] !== 'dimension') continue;
414
+ if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
415
+ target.$extensions ??= {};
416
+ target.$extensions[EXT_NS] ??= {};
417
+ const ns = target.$extensions[EXT_NS];
418
+ if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
351
419
  }
352
420
  return node;
353
421
  }
354
422
 
355
423
  export function preprocess(dict) {
356
424
  const collisions = [];
425
+ // Read from the UNRESOLVED dict, before resolveInPlace flattens the aliases
426
+ // the graph is made of.
427
+ const { typographic } = textRoleGraph(dict);
428
+ const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
429
+ // DTCG 5.2.2 types for the whole tree, walked once and shared by both passes
430
+ // below. Neither writes $type or moves a node, so one map is correct for
431
+ // both, and sharing it is what makes them agree by construction rather than
432
+ // by two copies of the same rule staying in step (#85).
433
+ //
434
+ // Computed on the RESOLVED clone, which is the tree both passes read.
435
+ // resolveInPlace rewrites $value strings only, so the types are the source's.
436
+ const types = flattenDtcgTypes(resolved);
357
437
  const out = hoistDualNodes(
358
- classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
438
+ applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
359
439
  collisions,
360
440
  );
361
441
  if (collisions.length) {
@@ -400,10 +480,14 @@ now emit `sp`, with the Swift output byte-identical.
400
480
 
401
481
  What remains:
402
482
 
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.
483
+ - **A scale primitive nothing references emits as `dp`.** `text.base: "16px"`
484
+ is a font size only to a human, so #63 takes the role from the reference
485
+ graph instead: a dimension referenced only by `fontSize`, `letterSpacing` or
486
+ `lineHeight` members is stamped typographic too. That is structural rather
487
+ than nominal, so it needs no path convention. A primitive **nothing**
488
+ references has no signal at all and is not inferred —
489
+ `tokens:validate-output` names it with an `unreferenced-text-sibling`
490
+ advisory, and a source-side `nativeUnit` stamp settles it.
407
491
  - **An `em` letter spacing reaches Compose but not Swift.** `size/unit-aware/compose-em`
408
492
  emits it as a real `.em` TextUnit, parenthesised — `(-0.03).em` — because
409
493
  `-0.03.em` parses as `-(0.03.em)` and needs an `unaryMinus` operator, while
@@ -448,14 +532,24 @@ dependency on it.
448
532
  // That last one is fixed for tokens whose role a DTCG source actually states:
449
533
  // classifyTextUnits stamps fontSize, letterSpacing and lineHeight members, and
450
534
  // 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.
535
+ // Two limits remain, one Android-only and one affecting both platforms, both
536
+ // measured rather than theoretical — see
537
+ // docs/superpowers/notes/2026-08-28-text-role-inference-e2e.md:
538
+ //
539
+ // - A scale primitive states no role, so #63 infers one from the reference
540
+ // graph: a dimension referenced only by fontSize, letterSpacing or
541
+ // lineHeight members is itself typographic. text.base: "16px" is stamped
542
+ // because a fontSize references it. A primitive NOTHING references stays
543
+ // dp on Android — no structural signal exists for it, so it is not
544
+ // inferred, and tokens:validate-output raises an unreferenced-text-sibling
545
+ // advisory naming it rather than leaving the gap silent. This is not the
546
+ // complete list of remaining limits (spec §8 names five more); the one a
547
+ // consumer is likeliest to hit is mode dependence — the same token can
548
+ // emit sp in one build and dp in another, because the graph only sees the
549
+ // files that build includes.
550
+ // - An em-valued letterSpacing reaches Compose as a real .em TextUnit since
551
+ // #64, but only where the text role is stamped. A role-less em value is
552
+ // still filtered out of native output entirely, on both platforms.
459
553
  //
460
554
  // The third — a unitless ratio emitting as dp — is fixed by #52: no size
461
555
  // transform claims a unitless value, so it emits bare on both platforms, and
@@ -70,6 +70,13 @@ no native form at all — a CSS `linear-gradient(...)` — is filtered out of na
70
70
  output rather than emitted broken. That drop is reported as an unemitted token,
71
71
  not hidden.
72
72
 
73
+ Since 0.17.0 that gap is measured rather than assumed on ThroughLine's side:
74
+ this project's end-to-end runs compile what they generate — `kotlinc` typechecks
75
+ `Tokens.kt` against Compose stubs, `swiftc -parse` checks `Tokens.swift` —
76
+ against a real 322-token system. That is evidence about the adapter shipped to
77
+ you, not a gate on your build, and it does not widen what the badge asserts
78
+ about your output.
79
+
73
80
  `android-kotlin` uses the same module and stays Tier 2: its remaining unknowns
74
81
  are on the consumption side — Compose `dp`/`sp` behaviour against a real Compose
75
82
  app, resource-qualifier conventions, package layout — which building tokens does
@@ -77,9 +84,12 @@ not exercise. The `dp`/`sp` split itself is no longer among them: font sizes and
77
84
  line heights whose role a DTCG source states — the `fontSize`, `letterSpacing`
78
85
  and `lineHeight` member names of §9.8's typography composite — now emit as
79
86
  `sp`. What remains is narrower and documented in
80
- `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`: a bare scale
81
- primitive carries no role and stays `dp`, and an `em` letterSpacing is
82
- filtered out rather than emitted as `.em`.
87
+ `${CLAUDE_PLUGIN_ROOT}/references/native-adapter-config.md`: a scale primitive
88
+ **nothing references** carries no role and stays `dp`, named by a
89
+ `tokens:validate-output` `unreferenced-text-sibling` advisory; and an `em`
90
+ letterSpacing reaches Compose as a real `.em` TextUnit but is excluded from
91
+ Swift deliberately, since letter spacing there needs a font size no constant
92
+ Swift value could carry.
83
93
  `tokens:validate-output` remains what decides whether any adapter can be
84
94
  trusted, and re-promotion is available to any adapter that passes it against a
85
95
  real source.
@@ -123,10 +123,14 @@ now emit \`sp\`, with the Swift output byte-identical.
123
123
 
124
124
  What remains:
125
125
 
126
- - **A bare scale primitive emits as \`dp\`.** \`text.base: "16px"\` is a font size
127
- only to a human no nominal or structural signal marks it so it is not
128
- stamped. The semantic tokens that reference it are, and those are what a
129
- consumer should reach for.
126
+ - **A scale primitive nothing references emits as \`dp\`.** \`text.base: "16px"\`
127
+ is a font size only to a human, so #63 takes the role from the reference
128
+ graph instead: a dimension referenced only by \`fontSize\`, \`letterSpacing\` or
129
+ \`lineHeight\` members is stamped typographic too. That is structural rather
130
+ than nominal, so it needs no path convention. A primitive **nothing**
131
+ references has no signal at all and is not inferred —
132
+ \`tokens:validate-output\` names it with an \`unreferenced-text-sibling\`
133
+ advisory, and a source-side \`nativeUnit\` stamp settles it.
130
134
  - **An \`em\` letter spacing reaches Compose but not Swift.** \`size/unit-aware/compose-em\`
131
135
  emits it as a real \`.em\` TextUnit, parenthesised — \`(-0.03).em\` — because
132
136
  \`-0.03.em\` parses as \`-(0.03.em)\` and needs an \`unaryMinus\` operator, while
@@ -4,6 +4,18 @@
4
4
 
5
5
  const REF = /^\{([^}]+)\}$/;
6
6
 
7
+ // The typographic member names DTCG §9.8 fixes at MUST level, the unit gate a
8
+ // text-role dimension must pass, and this project's $extensions namespace.
9
+ //
10
+ // They live here rather than in sd-native.mjs because textRoleGraph below and
11
+ // sd-native.mjs's preprocess apply the identical rules, and sd-native.mjs
12
+ // already imports this file — so the reverse import would be a cycle. Their
13
+ // full rationale stays at the point of use in sd-native.mjs, which is what the
14
+ // generated references/native-adapter-config.md renders.
15
+ export const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight']);
16
+ export const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
17
+ export const EXT_NS = 'com.radicool.throughline';
18
+
7
19
  // Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
8
20
  //
9
21
  // A node carrying BOTH a $value and children yields its own value AND is descended
@@ -85,3 +97,113 @@ export function findModeCollisions(sources) {
85
97
  }
86
98
  return collisions;
87
99
  }
100
+
101
+ // Deep merge in list order, later source winning — the same later-wins rule
102
+ // validate-token-output.mjs already applies when it flattens a source list, and
103
+ // what Style Dictionary hands preprocess as one dict.
104
+ //
105
+ // Each source is cloned on the way in. Merging the caller's own objects would
106
+ // mutate the token trees it still holds, and the validator reads them again.
107
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
108
+
109
+ function mergeInto(target, src) {
110
+ for (const [key, val] of Object.entries(src)) {
111
+ if (isPlainObject(val) && isPlainObject(target[key])) mergeInto(target[key], val);
112
+ else target[key] = val;
113
+ }
114
+ return target;
115
+ }
116
+
117
+ export function mergeDtcg(dicts) {
118
+ const out = {};
119
+ for (const dict of dicts) mergeInto(out, structuredClone(dict));
120
+ return out;
121
+ }
122
+
123
+ // A dimension primitive states no typographic role: text.base: "16px" is a font
124
+ // size only to a human, so it emitted as dp, and an em letterSpacing primitive
125
+ // was dropped from native output entirely. #51 sources the role from the member
126
+ // names DTCG §9.8 fixes, which reaches the semantic tokens and not the
127
+ // primitives they reference. This reaches the primitives, structurally.
128
+ //
129
+ // Reads the UNRESOLVED tree: preprocess resolves aliases in place, so by
130
+ // transform time the graph is gone. Nothing needs carrying, because the
131
+ // inference is applied during preprocessing and only the $extensions stamp
132
+ // survives — see sd-native.mjs's applyTextRoleGraph.
133
+ //
134
+ // A referrer whose leaf name is NOT typographic is counter-evidence, not
135
+ // neutral. A dimension referenced by something that is not a typographic member
136
+ // is a length, which is exactly what the dp default already asserts about it.
137
+ // Treating it as neutral would let one stray fontSize reference convert a whole
138
+ // spacing ramp.
139
+ //
140
+ // Single-pass and deliberately not transitive: a chain through an intermediate
141
+ // whose own leaf name states no role is declined at the second hop, because
142
+ // that intermediate is itself counter-evidence. Only whole-value references
143
+ // count; a reference embedded in an expression is resolved by resolveInPlace
144
+ // but is not evidence of a role.
145
+ export function textRoleGraph(dict) {
146
+ const edges = [];
147
+ (function walk(node, prefix) {
148
+ for (const [key, val] of Object.entries(node)) {
149
+ if (key.startsWith('$') || !isPlainObject(val)) continue;
150
+ const path = [...prefix, key];
151
+ if (typeof val.$value === 'string') {
152
+ const m = REF.exec(val.$value.trim());
153
+ if (m) edges.push({ to: m[1], leaf: key });
154
+ }
155
+ walk(val, path);
156
+ }
157
+ })(dict, []);
158
+
159
+ const referrers = new Map();
160
+ for (const edge of edges) {
161
+ if (!referrers.has(edge.to)) referrers.set(edge.to, []);
162
+ referrers.get(edge.to).push(edge);
163
+ }
164
+
165
+ const typographic = new Set();
166
+ const ambiguous = [];
167
+ for (const [path, rs] of referrers) {
168
+ const textLeaves = [...new Set(rs.filter((r) => TEXT_UNIT_NAMES.has(r.leaf)).map((r) => r.leaf))];
169
+ if (textLeaves.length === 0) continue;
170
+ const otherLeaves = [...new Set(rs.filter((r) => !TEXT_UNIT_NAMES.has(r.leaf)).map((r) => r.leaf))];
171
+ if (otherLeaves.length) ambiguous.push({ path, textLeaves, otherLeaves });
172
+ else typographic.add(path);
173
+ }
174
+
175
+ // A primitive nothing references has no structural signal at all, so it is
176
+ // never inferred. Reported instead, where its group holds one that was: that
177
+ // is the strongest hint available without guessing, and a silent gap is the
178
+ // failure this module exists to prevent. A token whose source already stamps
179
+ // nativeUnit is closed and is not reported.
180
+ const inferredGroups = new Set([...typographic].map((p) => p.split('.').slice(0, -1).join('.')));
181
+ // DTCG 5.2.2, not the token's own literal $type: a source that declares
182
+ // $type once on the group and not on each token is legal DTCG, and gating on
183
+ // val.$type made this walk blind to it — so the advisory that exists to name
184
+ // a silent gap was itself silent on the shape where the whole pipeline goes
185
+ // quiet (#85).
186
+ const types = flattenDtcgTypes(dict);
187
+ const unreferencedSiblings = [];
188
+ (function walk(node, prefix) {
189
+ for (const [key, val] of Object.entries(node)) {
190
+ if (key.startsWith('$') || !isPlainObject(val)) continue;
191
+ const path = [...prefix, key];
192
+ const dotted = path.join('.');
193
+ const group = prefix.join('.');
194
+ if (
195
+ '$value' in val &&
196
+ types[dotted] === 'dimension' &&
197
+ TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
198
+ !referrers.has(dotted) &&
199
+ !('nativeUnit' in (val.$extensions?.[EXT_NS] ?? {})) &&
200
+ inferredGroups.has(group)
201
+ ) {
202
+ unreferencedSiblings.push({ path: dotted, group });
203
+ }
204
+ walk(val, path);
205
+ }
206
+ })(dict, []);
207
+
208
+ return { typographic, ambiguous, unreferencedSiblings };
209
+ }
@@ -10,7 +10,16 @@
10
10
  // scripts/build-native-adapter-config.mjs. Edit the code here, then regenerate.
11
11
  // @doc-section imports
12
12
  import { readFileSync } from 'node:fs';
13
- import { flattenDtcg, resolveValue, findModeCollisions } from './dtcg.mjs';
13
+ import {
14
+ flattenDtcg,
15
+ flattenDtcgTypes,
16
+ resolveValue,
17
+ findModeCollisions,
18
+ TEXT_UNIT_NAMES,
19
+ TEXT_ROLE_UNIT,
20
+ EXT_NS,
21
+ textRoleGraph,
22
+ } from './dtcg.mjs';
14
23
  import { isValidLiteral, GRAMMAR, CSS_CONSTRUCT } from './native-literal.mjs';
15
24
  // @doc-section-end imports
16
25
 
@@ -226,11 +235,16 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
226
235
  // sibling tokens in a group. Reading them there mirrors the spec's vocabulary;
227
236
  // it is not a guarantee the spec makes. A source naming its font size
228
237
  // typography.body.size sets $extensions itself and is honoured below.
229
- const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight']);
238
+ //
239
+ // Defined in lib/dtcg.mjs and imported above, so textRoleGraph applies the
240
+ // identical set. Two definitions of this set would drift.
230
241
 
231
242
  // Reverse-DNS, per DTCG's $extensions convention. Exported because the
232
243
  // transforms and their tests address the same key.
233
- export const EXT_NS = 'com.radicool.throughline';
244
+ //
245
+ // Defined in lib/dtcg.mjs and re-exported here: the transforms and their tests
246
+ // address this key through sd-native.mjs, and that surface does not move.
247
+ export { EXT_NS };
234
248
 
235
249
  // px and rem only. magnitude() reads a bare number as an unscaled ratio, so a
236
250
  // lineHeight authored "1.5" would otherwise be stamped and emit 1.50.sp —
@@ -244,7 +258,9 @@ export const EXT_NS = 'com.radicool.throughline';
244
258
  // em-valued letterSpacing is a text-role dimension like any other. It is still
245
259
  // a unit — the gate's job is to exclude the UNITLESS value, whose role the
246
260
  // source never stated.
247
- const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
261
+ //
262
+ // Defined in lib/dtcg.mjs and imported above, for the same reason as
263
+ // TEXT_UNIT_NAMES: textRoleGraph gates on it too.
248
264
 
249
265
  // Runs AFTER resolveInPlace and BEFORE hoistDualNodes. Both halves matter.
250
266
  //
@@ -258,13 +274,32 @@ const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
258
274
  // on is gone. Matching a suffix against the camel-joined name instead would
259
275
  // couple the rule to the hoist's naming scheme, and case-insensitively it
260
276
  // false-positives on names like baselineHeight.
261
- function classifyTextUnits(node) {
277
+ //
278
+ // The type comes from `types`, a DTCG 5.2.2 resolution of the whole tree, not
279
+ // from the token's own literal $type. Reading val.$type made this pass blind to
280
+ // a source that declares $type once on the group — legal DTCG, and on such a
281
+ // source the reference-graph inference stamped nothing at all (#85). A token's
282
+ // own $type still wins; the group's applies only where the token states none.
283
+ //
284
+ // WHERE THIS MATTERS, measured rather than assumed. Style Dictionary runs
285
+ // global preprocessors, THEN its own typeDtcgDelegate — which is 5.2.2, pushing
286
+ // each group's $type onto its descendants — then platform preprocessors
287
+ // (StyleDictionary.js:340, :348, :440 in 4.4.0). nativePlatform registers this
288
+ // preprocessor at PLATFORM level, downstream of that delegation, so a build
289
+ // wired only through nativePlatform never saw the defect: on a group-typed
290
+ // re-encoding of a real system it emits the same 208 declarations and 48 sp
291
+ // either way. The defect reaches the build that ALSO declares this preprocessor
292
+ // at top level, which is the wiring the usage snippet above shows — there the
293
+ // first pass runs before any delegation. Resolving the type here makes both
294
+ // wirings agree instead of depending on which one a consumer copied.
295
+ function classifyTextUnits(node, types, prefix = []) {
262
296
  for (const [key, val] of Object.entries(node)) {
263
297
  if (key.startsWith('$') || !val || typeof val !== 'object') continue;
298
+ const path = [...prefix, key];
264
299
  if (
265
300
  TEXT_UNIT_NAMES.has(key) &&
266
301
  '$value' in val &&
267
- val.$type === 'dimension' &&
302
+ types[path.join('.')] === 'dimension' &&
268
303
  TEXT_ROLE_UNIT.test(String(val.$value).trim())
269
304
  ) {
270
305
  val.$extensions ??= {};
@@ -278,15 +313,60 @@ function classifyTextUnits(node) {
278
313
  // the pass idempotent.
279
314
  if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
280
315
  }
281
- classifyTextUnits(val);
316
+ classifyTextUnits(val, types, path);
317
+ }
318
+ return node;
319
+ }
320
+
321
+ // Runs AFTER classifyTextUnits and BEFORE hoistDualNodes, on the SAME two
322
+ // grounds that pass gives: after, so a role the source or the member name
323
+ // already stated wins; before, because the hoist rewrites text.xs.lineHeight to
324
+ // text.xsLineHeight and the graph's paths are written in pre-hoist names.
325
+ //
326
+ // The three gates are classifyTextUnits's, verbatim — a dimension, a value with
327
+ // a unit, and no role already recorded. Both passes read the SAME resolved-type
328
+ // map, which is what makes them agree by construction rather than by two copies
329
+ // of DTCG 5.2.2 staying in step. A unitless value is never stamped: no
330
+ // size transform claims one since #52, and stamping a ratio as text would still
331
+ // be a claim the source never made.
332
+ //
333
+ // A path may name no node at all. resolveInPlace deliberately leaves an
334
+ // unresolvable reference in place for Style Dictionary to report, so the graph
335
+ // can hold an edge to a token that does not exist. Skip it. This is also what
336
+ // keeps the second preprocess pass from throwing, and idempotency with it.
337
+ function applyTextRoleGraph(node, typographic, types) {
338
+ for (const path of typographic) {
339
+ let target = node;
340
+ for (const segment of path.split('.')) {
341
+ target = target && typeof target === 'object' ? target[segment] : undefined;
342
+ }
343
+ if (!target || typeof target !== 'object' || !('$value' in target)) continue;
344
+ if (types[path] !== 'dimension') continue;
345
+ if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
346
+ target.$extensions ??= {};
347
+ target.$extensions[EXT_NS] ??= {};
348
+ const ns = target.$extensions[EXT_NS];
349
+ if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
282
350
  }
283
351
  return node;
284
352
  }
285
353
 
286
354
  export function preprocess(dict) {
287
355
  const collisions = [];
356
+ // Read from the UNRESOLVED dict, before resolveInPlace flattens the aliases
357
+ // the graph is made of.
358
+ const { typographic } = textRoleGraph(dict);
359
+ const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
360
+ // DTCG 5.2.2 types for the whole tree, walked once and shared by both passes
361
+ // below. Neither writes $type or moves a node, so one map is correct for
362
+ // both, and sharing it is what makes them agree by construction rather than
363
+ // by two copies of the same rule staying in step (#85).
364
+ //
365
+ // Computed on the RESOLVED clone, which is the tree both passes read.
366
+ // resolveInPlace rewrites $value strings only, so the types are the source's.
367
+ const types = flattenDtcgTypes(resolved);
288
368
  const out = hoistDualNodes(
289
- classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
369
+ applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
290
370
  collisions,
291
371
  );
292
372
  if (collisions.length) {
@@ -324,14 +404,24 @@ export function preprocess(dict) {
324
404
  // That last one is fixed for tokens whose role a DTCG source actually states:
325
405
  // classifyTextUnits stamps fontSize, letterSpacing and lineHeight members, and
326
406
  // the sp transform gates on the stamp rather than on a $type DTCG never emits.
327
- // Two limits remain, both Android-only and both measured rather than
328
- // theoretical — see docs/superpowers/notes/2026-08-21-native-config-e2e-results.md:
329
- //
330
- // - A scale primitive carries no role. text.base: "16px" is a font size only
331
- // to a human, so it emits as dp. The semantic tokens referencing it are
332
- // correct, and those are what a consumer should reach for.
333
- // - An em-valued letterSpacing is filtered out of native output entirely,
334
- // rather than emitted as Compose's .em TextUnit.
407
+ // Two limits remain, one Android-only and one affecting both platforms, both
408
+ // measured rather than theoretical — see
409
+ // docs/superpowers/notes/2026-08-28-text-role-inference-e2e.md:
410
+ //
411
+ // - A scale primitive states no role, so #63 infers one from the reference
412
+ // graph: a dimension referenced only by fontSize, letterSpacing or
413
+ // lineHeight members is itself typographic. text.base: "16px" is stamped
414
+ // because a fontSize references it. A primitive NOTHING references stays
415
+ // dp on Android — no structural signal exists for it, so it is not
416
+ // inferred, and tokens:validate-output raises an unreferenced-text-sibling
417
+ // advisory naming it rather than leaving the gap silent. This is not the
418
+ // complete list of remaining limits (spec §8 names five more); the one a
419
+ // consumer is likeliest to hit is mode dependence — the same token can
420
+ // emit sp in one build and dp in another, because the graph only sees the
421
+ // files that build includes.
422
+ // - An em-valued letterSpacing reaches Compose as a real .em TextUnit since
423
+ // #64, but only where the text role is stamped. A role-less em value is
424
+ // still filtered out of native output entirely, on both platforms.
335
425
  //
336
426
  // The third — a unitless ratio emitting as dp — is fixed by #52: no size
337
427
  // transform claims a unitless value, so it emits bare on both platforms, and
@@ -7,7 +7,15 @@
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { parseArgs } from 'node:util';
9
9
  import { pathToFileURL } from 'node:url';
10
- import { flattenDtcg, flattenDtcgTypes, resolveValue, findModeCollisions } from './lib/dtcg.mjs';
10
+ import {
11
+ flattenDtcg,
12
+ flattenDtcgTypes,
13
+ resolveValue,
14
+ findModeCollisions,
15
+ textRoleGraph,
16
+ mergeDtcg,
17
+ EXT_NS,
18
+ } from './lib/dtcg.mjs';
11
19
  import { parseLiteral, isValidLiteral, GRAMMAR } from './lib/native-literal.mjs';
12
20
 
13
21
  // Re-exported so consumers (and the test file) keep one import surface.
@@ -209,6 +217,24 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
209
217
  }
210
218
  }
211
219
 
220
+ // A SOURCE-side pass, deliberately not part of the loop above. That loop
221
+ // iterates emitted declarations, and the token this advisory exists for is
222
+ // the one that was never emitted at all — an em letterSpacing whose role
223
+ // nothing states is filtered out of native output, so it has no symbol to
224
+ // hang a note on.
225
+ //
226
+ // Merged rather than unioned across sources: the graph must describe the
227
+ // build that actually ran, and a build merges with the later source winning.
228
+ // A union would call a token referenced when this build did not reach it,
229
+ // under-reporting the gap in the one direction that matters.
230
+ const graph = textRoleGraph(mergeDtcg(sources.map((s) => s.dtcg)));
231
+ for (const { path, group } of graph.unreferencedSiblings) {
232
+ advisories.push({ rule: 'unreferenced-text-sibling', token: path, group });
233
+ }
234
+ for (const { path, textLeaves, otherLeaves } of graph.ambiguous) {
235
+ advisories.push({ rule: 'ambiguous-text-role', token: path, textLeaves, otherLeaves });
236
+ }
237
+
212
238
  const matchRate = decls.length ? matched / decls.length : 0;
213
239
  const ok = failures.length === 0 && collisions.length === 0 && matched > 0 && matchRate >= minMatch;
214
240
 
@@ -260,6 +286,18 @@ export function formatReport(r) {
260
286
  if (r.advisories?.length) {
261
287
  lines.push(`\n${r.advisories.length} advisory note(s) — reported, not gating:`);
262
288
  for (const a of r.advisories) {
289
+ if (a.rule === 'unreferenced-text-sibling') {
290
+ lines.push(
291
+ ` - [${a.rule}] ${a.token}: nothing references it, so no typographic role could be inferred — but tokens in "${a.group}" were. It emits as a length, or is dropped entirely if its unit is em. On Compose, stamping $extensions["${EXT_NS}"].nativeUnit = "text" on it in source settles it; on Swift there is no sp/dp distinction to settle, and a stamped em still would not emit there. Leave it as is if it is not a text value.`,
292
+ );
293
+ continue;
294
+ }
295
+ if (a.rule === 'ambiguous-text-role') {
296
+ lines.push(
297
+ ` - [${a.rule}] ${a.token}: referenced both by typographic member(s) [${a.textLeaves.join(', ')}] and by [${a.otherLeaves.join(', ')}], so no role was inferred rather than a role being guessed. Stamp $extensions["${EXT_NS}"].nativeUnit in source to settle it.`,
298
+ );
299
+ continue;
300
+ }
263
301
  lines.push(
264
302
  ` - [${a.rule}] ${a.symbol}: source ${JSON.stringify(a.source)} for ${a.token} is a dimension with no unit, which DTCG §8.2.1 does not permit. It emitted ${a.emitted}, read as a ratio. If it is a ratio, type it "number" (§8.7); if it is a measurement, add the unit you meant.`,
265
303
  );