@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 +1 -1
- package/references/native-adapter-config.md +114 -20
- package/references/sync-adapters.md +13 -3
- package/scripts/build-native-adapter-config.mjs +8 -4
- package/scripts/lib/dtcg.mjs +122 -0
- package/scripts/lib/sd-native.mjs +106 -16
- 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.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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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,
|
|
452
|
-
// theoretical — see
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
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
|
|
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,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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
328
|
-
// theoretical — see
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
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 {
|
|
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
|
);
|