@radicool/throughline 0.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radicool/throughline",
3
- "version": "0.16.0",
3
+ "version": "0.17.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,15 @@ 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
+ resolveValue,
58
+ findModeCollisions,
59
+ TEXT_UNIT_NAMES,
60
+ TEXT_ROLE_UNIT,
61
+ EXT_NS,
62
+ textRoleGraph,
63
+ } from './dtcg.mjs';
56
64
  import { isValidLiteral, GRAMMAR, CSS_CONSTRUCT } from './native-literal.mjs';
57
65
  ```
58
66
 
@@ -295,11 +303,16 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
295
303
  // sibling tokens in a group. Reading them there mirrors the spec's vocabulary;
296
304
  // it is not a guarantee the spec makes. A source naming its font size
297
305
  // typography.body.size sets $extensions itself and is honoured below.
298
- const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight']);
306
+ //
307
+ // Defined in lib/dtcg.mjs and imported above, so textRoleGraph applies the
308
+ // identical set. Two definitions of this set would drift.
299
309
 
300
310
  // Reverse-DNS, per DTCG's $extensions convention. Exported because the
301
311
  // transforms and their tests address the same key.
302
- export const EXT_NS = 'com.radicool.throughline';
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 };
303
316
 
304
317
  // px and rem only. magnitude() reads a bare number as an unscaled ratio, so a
305
318
  // lineHeight authored "1.5" would otherwise be stamped and emit 1.50.sp —
@@ -313,7 +326,9 @@ export const EXT_NS = 'com.radicool.throughline';
313
326
  // em-valued letterSpacing is a text-role dimension like any other. It is still
314
327
  // a unit — the gate's job is to exclude the UNITLESS value, whose role the
315
328
  // source never stated.
316
- const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
329
+ //
330
+ // Defined in lib/dtcg.mjs and imported above, for the same reason as
331
+ // TEXT_UNIT_NAMES: textRoleGraph gates on it too.
317
332
 
318
333
  // Runs AFTER resolveInPlace and BEFORE hoistDualNodes. Both halves matter.
319
334
  //
@@ -352,10 +367,47 @@ function classifyTextUnits(node) {
352
367
  return node;
353
368
  }
354
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
+
355
401
  export function preprocess(dict) {
356
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);
357
406
  const out = hoistDualNodes(
358
- classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
407
+ applyTextRoleGraph(
408
+ classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
409
+ typographic,
410
+ ),
359
411
  collisions,
360
412
  );
361
413
  if (collisions.length) {
@@ -400,10 +452,14 @@ now emit `sp`, with the Swift output byte-identical.
400
452
 
401
453
  What remains:
402
454
 
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.
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.
407
463
  - **An `em` letter spacing reaches Compose but not Swift.** `size/unit-aware/compose-em`
408
464
  emits it as a real `.em` TextUnit, parenthesised — `(-0.03).em` — because
409
465
  `-0.03.em` parses as `-(0.03.em)` and needs an `unaryMinus` operator, while
@@ -448,14 +504,24 @@ dependency on it.
448
504
  // That last one is fixed for tokens whose role a DTCG source actually states:
449
505
  // classifyTextUnits stamps fontSize, letterSpacing and lineHeight members, and
450
506
  // 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.
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.
459
525
  //
460
526
  // The third — a unitless ratio emitting as dp — is fixed by #52: no size
461
527
  // 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,107 @@ 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
+ const unreferencedSiblings = [];
182
+ (function walk(node, prefix) {
183
+ for (const [key, val] of Object.entries(node)) {
184
+ if (key.startsWith('$') || !isPlainObject(val)) continue;
185
+ const path = [...prefix, key];
186
+ const dotted = path.join('.');
187
+ const group = prefix.join('.');
188
+ if (
189
+ '$value' in val &&
190
+ val.$type === 'dimension' &&
191
+ TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
192
+ !referrers.has(dotted) &&
193
+ !('nativeUnit' in (val.$extensions?.[EXT_NS] ?? {})) &&
194
+ inferredGroups.has(group)
195
+ ) {
196
+ unreferencedSiblings.push({ path: dotted, group });
197
+ }
198
+ walk(val, path);
199
+ }
200
+ })(dict, []);
201
+
202
+ return { typographic, ambiguous, unreferencedSiblings };
203
+ }
@@ -10,7 +10,15 @@
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
+ resolveValue,
16
+ findModeCollisions,
17
+ TEXT_UNIT_NAMES,
18
+ TEXT_ROLE_UNIT,
19
+ EXT_NS,
20
+ textRoleGraph,
21
+ } from './dtcg.mjs';
14
22
  import { isValidLiteral, GRAMMAR, CSS_CONSTRUCT } from './native-literal.mjs';
15
23
  // @doc-section-end imports
16
24
 
@@ -226,11 +234,16 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
226
234
  // sibling tokens in a group. Reading them there mirrors the spec's vocabulary;
227
235
  // it is not a guarantee the spec makes. A source naming its font size
228
236
  // typography.body.size sets $extensions itself and is honoured below.
229
- const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight']);
237
+ //
238
+ // Defined in lib/dtcg.mjs and imported above, so textRoleGraph applies the
239
+ // identical set. Two definitions of this set would drift.
230
240
 
231
241
  // Reverse-DNS, per DTCG's $extensions convention. Exported because the
232
242
  // transforms and their tests address the same key.
233
- export const EXT_NS = 'com.radicool.throughline';
243
+ //
244
+ // Defined in lib/dtcg.mjs and re-exported here: the transforms and their tests
245
+ // address this key through sd-native.mjs, and that surface does not move.
246
+ export { EXT_NS };
234
247
 
235
248
  // px and rem only. magnitude() reads a bare number as an unscaled ratio, so a
236
249
  // lineHeight authored "1.5" would otherwise be stamped and emit 1.50.sp —
@@ -244,7 +257,9 @@ export const EXT_NS = 'com.radicool.throughline';
244
257
  // em-valued letterSpacing is a text-role dimension like any other. It is still
245
258
  // a unit — the gate's job is to exclude the UNITLESS value, whose role the
246
259
  // source never stated.
247
- const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
260
+ //
261
+ // Defined in lib/dtcg.mjs and imported above, for the same reason as
262
+ // TEXT_UNIT_NAMES: textRoleGraph gates on it too.
248
263
 
249
264
  // Runs AFTER resolveInPlace and BEFORE hoistDualNodes. Both halves matter.
250
265
  //
@@ -283,10 +298,47 @@ function classifyTextUnits(node) {
283
298
  return node;
284
299
  }
285
300
 
301
+ // Runs AFTER classifyTextUnits and BEFORE hoistDualNodes, on the SAME two
302
+ // grounds that pass gives: after, so a role the source or the member name
303
+ // already stated wins; before, because the hoist rewrites text.xs.lineHeight to
304
+ // text.xsLineHeight and the graph's paths are written in pre-hoist names.
305
+ //
306
+ // The three gates are classifyTextUnits's, verbatim — a dimension, a value with
307
+ // a unit, and no role already recorded. A unitless value is never stamped: no
308
+ // size transform claims one since #52, and stamping a ratio as text would still
309
+ // be a claim the source never made.
310
+ //
311
+ // A path may name no node at all. resolveInPlace deliberately leaves an
312
+ // unresolvable reference in place for Style Dictionary to report, so the graph
313
+ // can hold an edge to a token that does not exist. Skip it. This is also what
314
+ // keeps the second preprocess pass from throwing, and idempotency with it.
315
+ function applyTextRoleGraph(node, typographic) {
316
+ for (const path of typographic) {
317
+ let target = node;
318
+ for (const segment of path.split('.')) {
319
+ target = target && typeof target === 'object' ? target[segment] : undefined;
320
+ }
321
+ if (!target || typeof target !== 'object' || !('$value' in target)) continue;
322
+ if (target.$type !== 'dimension') continue;
323
+ if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
324
+ target.$extensions ??= {};
325
+ target.$extensions[EXT_NS] ??= {};
326
+ const ns = target.$extensions[EXT_NS];
327
+ if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
328
+ }
329
+ return node;
330
+ }
331
+
286
332
  export function preprocess(dict) {
287
333
  const collisions = [];
334
+ // Read from the UNRESOLVED dict, before resolveInPlace flattens the aliases
335
+ // the graph is made of.
336
+ const { typographic } = textRoleGraph(dict);
288
337
  const out = hoistDualNodes(
289
- classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
338
+ applyTextRoleGraph(
339
+ classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
340
+ typographic,
341
+ ),
290
342
  collisions,
291
343
  );
292
344
  if (collisions.length) {
@@ -324,14 +376,24 @@ export function preprocess(dict) {
324
376
  // That last one is fixed for tokens whose role a DTCG source actually states:
325
377
  // classifyTextUnits stamps fontSize, letterSpacing and lineHeight members, and
326
378
  // 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.
379
+ // Two limits remain, one Android-only and one affecting both platforms, both
380
+ // measured rather than theoretical — see
381
+ // docs/superpowers/notes/2026-08-28-text-role-inference-e2e.md:
382
+ //
383
+ // - A scale primitive states no role, so #63 infers one from the reference
384
+ // graph: a dimension referenced only by fontSize, letterSpacing or
385
+ // lineHeight members is itself typographic. text.base: "16px" is stamped
386
+ // because a fontSize references it. A primitive NOTHING references stays
387
+ // dp on Android — no structural signal exists for it, so it is not
388
+ // inferred, and tokens:validate-output raises an unreferenced-text-sibling
389
+ // advisory naming it rather than leaving the gap silent. This is not the
390
+ // complete list of remaining limits (spec §8 names five more); the one a
391
+ // consumer is likeliest to hit is mode dependence — the same token can
392
+ // emit sp in one build and dp in another, because the graph only sees the
393
+ // files that build includes.
394
+ // - An em-valued letterSpacing reaches Compose as a real .em TextUnit since
395
+ // #64, but only where the text role is stamped. A role-less em value is
396
+ // still filtered out of native output entirely, on both platforms.
335
397
  //
336
398
  // The third — a unitless ratio emitting as dp — is fixed by #52: no size
337
399
  // 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
  );