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