@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 +1 -1
- package/references/native-adapter-config.md +83 -17
- package/references/sync-adapters.md +13 -3
- package/scripts/build-native-adapter-config.mjs +8 -4
- package/scripts/lib/dtcg.mjs +116 -0
- package/scripts/lib/sd-native.mjs +75 -13
- package/scripts/validate-token-output.mjs +39 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radicool/throughline",
|
|
3
|
-
"version": "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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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,
|
|
452
|
-
// theoretical — see
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
package/scripts/lib/dtcg.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
328
|
-
// theoretical — see
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
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 {
|
|
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
|
);
|