@radicool/throughline 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radicool/throughline",
3
- "version": "0.17.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": {
@@ -54,6 +54,7 @@ consumer's repo.
54
54
  import { readFileSync } from 'node:fs';
55
55
  import {
56
56
  flattenDtcg,
57
+ flattenDtcgTypes,
57
58
  resolveValue,
58
59
  findModeCollisions,
59
60
  TEXT_UNIT_NAMES,
@@ -342,13 +343,32 @@ export { EXT_NS };
342
343
  // on is gone. Matching a suffix against the camel-joined name instead would
343
344
  // couple the rule to the hoist's naming scheme, and case-insensitively it
344
345
  // false-positives on names like baselineHeight.
345
- function classifyTextUnits(node) {
346
+ //
347
+ // The type comes from `types`, a DTCG 5.2.2 resolution of the whole tree, not
348
+ // from the token's own literal $type. Reading val.$type made this pass blind to
349
+ // a source that declares $type once on the group — legal DTCG, and on such a
350
+ // source the reference-graph inference stamped nothing at all (#85). A token's
351
+ // own $type still wins; the group's applies only where the token states none.
352
+ //
353
+ // WHERE THIS MATTERS, measured rather than assumed. Style Dictionary runs
354
+ // global preprocessors, THEN its own typeDtcgDelegate — which is 5.2.2, pushing
355
+ // each group's $type onto its descendants — then platform preprocessors
356
+ // (StyleDictionary.js:340, :348, :440 in 4.4.0). nativePlatform registers this
357
+ // preprocessor at PLATFORM level, downstream of that delegation, so a build
358
+ // wired only through nativePlatform never saw the defect: on a group-typed
359
+ // re-encoding of a real system it emits the same 208 declarations and 48 sp
360
+ // either way. The defect reaches the build that ALSO declares this preprocessor
361
+ // at top level, which is the wiring the usage snippet above shows — there the
362
+ // first pass runs before any delegation. Resolving the type here makes both
363
+ // wirings agree instead of depending on which one a consumer copied.
364
+ function classifyTextUnits(node, types, prefix = []) {
346
365
  for (const [key, val] of Object.entries(node)) {
347
366
  if (key.startsWith('$') || !val || typeof val !== 'object') continue;
367
+ const path = [...prefix, key];
348
368
  if (
349
369
  TEXT_UNIT_NAMES.has(key) &&
350
370
  '$value' in val &&
351
- val.$type === 'dimension' &&
371
+ types[path.join('.')] === 'dimension' &&
352
372
  TEXT_ROLE_UNIT.test(String(val.$value).trim())
353
373
  ) {
354
374
  val.$extensions ??= {};
@@ -362,7 +382,7 @@ function classifyTextUnits(node) {
362
382
  // the pass idempotent.
363
383
  if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
364
384
  }
365
- classifyTextUnits(val);
385
+ classifyTextUnits(val, types, path);
366
386
  }
367
387
  return node;
368
388
  }
@@ -373,7 +393,9 @@ function classifyTextUnits(node) {
373
393
  // text.xsLineHeight and the graph's paths are written in pre-hoist names.
374
394
  //
375
395
  // The three gates are classifyTextUnits's, verbatim — a dimension, a value with
376
- // a unit, and no role already recorded. A unitless value is never stamped: no
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
377
399
  // size transform claims one since #52, and stamping a ratio as text would still
378
400
  // be a claim the source never made.
379
401
  //
@@ -381,14 +403,14 @@ function classifyTextUnits(node) {
381
403
  // unresolvable reference in place for Style Dictionary to report, so the graph
382
404
  // can hold an edge to a token that does not exist. Skip it. This is also what
383
405
  // keeps the second preprocess pass from throwing, and idempotency with it.
384
- function applyTextRoleGraph(node, typographic) {
406
+ function applyTextRoleGraph(node, typographic, types) {
385
407
  for (const path of typographic) {
386
408
  let target = node;
387
409
  for (const segment of path.split('.')) {
388
410
  target = target && typeof target === 'object' ? target[segment] : undefined;
389
411
  }
390
412
  if (!target || typeof target !== 'object' || !('$value' in target)) continue;
391
- if (target.$type !== 'dimension') continue;
413
+ if (types[path] !== 'dimension') continue;
392
414
  if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
393
415
  target.$extensions ??= {};
394
416
  target.$extensions[EXT_NS] ??= {};
@@ -403,11 +425,17 @@ export function preprocess(dict) {
403
425
  // Read from the UNRESOLVED dict, before resolveInPlace flattens the aliases
404
426
  // the graph is made of.
405
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);
406
437
  const out = hoistDualNodes(
407
- applyTextRoleGraph(
408
- classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
409
- typographic,
410
- ),
438
+ applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
411
439
  collisions,
412
440
  );
413
441
  if (collisions.length) {
@@ -178,6 +178,12 @@ export function textRoleGraph(dict) {
178
178
  // failure this module exists to prevent. A token whose source already stamps
179
179
  // nativeUnit is closed and is not reported.
180
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);
181
187
  const unreferencedSiblings = [];
182
188
  (function walk(node, prefix) {
183
189
  for (const [key, val] of Object.entries(node)) {
@@ -187,7 +193,7 @@ export function textRoleGraph(dict) {
187
193
  const group = prefix.join('.');
188
194
  if (
189
195
  '$value' in val &&
190
- val.$type === 'dimension' &&
196
+ types[dotted] === 'dimension' &&
191
197
  TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
192
198
  !referrers.has(dotted) &&
193
199
  !('nativeUnit' in (val.$extensions?.[EXT_NS] ?? {})) &&
@@ -12,6 +12,7 @@
12
12
  import { readFileSync } from 'node:fs';
13
13
  import {
14
14
  flattenDtcg,
15
+ flattenDtcgTypes,
15
16
  resolveValue,
16
17
  findModeCollisions,
17
18
  TEXT_UNIT_NAMES,
@@ -273,13 +274,32 @@ export { EXT_NS };
273
274
  // on is gone. Matching a suffix against the camel-joined name instead would
274
275
  // couple the rule to the hoist's naming scheme, and case-insensitively it
275
276
  // false-positives on names like baselineHeight.
276
- function classifyTextUnits(node) {
277
+ //
278
+ // The type comes from `types`, a DTCG 5.2.2 resolution of the whole tree, not
279
+ // from the token's own literal $type. Reading val.$type made this pass blind to
280
+ // a source that declares $type once on the group — legal DTCG, and on such a
281
+ // source the reference-graph inference stamped nothing at all (#85). A token's
282
+ // own $type still wins; the group's applies only where the token states none.
283
+ //
284
+ // WHERE THIS MATTERS, measured rather than assumed. Style Dictionary runs
285
+ // global preprocessors, THEN its own typeDtcgDelegate — which is 5.2.2, pushing
286
+ // each group's $type onto its descendants — then platform preprocessors
287
+ // (StyleDictionary.js:340, :348, :440 in 4.4.0). nativePlatform registers this
288
+ // preprocessor at PLATFORM level, downstream of that delegation, so a build
289
+ // wired only through nativePlatform never saw the defect: on a group-typed
290
+ // re-encoding of a real system it emits the same 208 declarations and 48 sp
291
+ // either way. The defect reaches the build that ALSO declares this preprocessor
292
+ // at top level, which is the wiring the usage snippet above shows — there the
293
+ // first pass runs before any delegation. Resolving the type here makes both
294
+ // wirings agree instead of depending on which one a consumer copied.
295
+ function classifyTextUnits(node, types, prefix = []) {
277
296
  for (const [key, val] of Object.entries(node)) {
278
297
  if (key.startsWith('$') || !val || typeof val !== 'object') continue;
298
+ const path = [...prefix, key];
279
299
  if (
280
300
  TEXT_UNIT_NAMES.has(key) &&
281
301
  '$value' in val &&
282
- val.$type === 'dimension' &&
302
+ types[path.join('.')] === 'dimension' &&
283
303
  TEXT_ROLE_UNIT.test(String(val.$value).trim())
284
304
  ) {
285
305
  val.$extensions ??= {};
@@ -293,7 +313,7 @@ function classifyTextUnits(node) {
293
313
  // the pass idempotent.
294
314
  if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
295
315
  }
296
- classifyTextUnits(val);
316
+ classifyTextUnits(val, types, path);
297
317
  }
298
318
  return node;
299
319
  }
@@ -304,7 +324,9 @@ function classifyTextUnits(node) {
304
324
  // text.xsLineHeight and the graph's paths are written in pre-hoist names.
305
325
  //
306
326
  // The three gates are classifyTextUnits's, verbatim — a dimension, a value with
307
- // a unit, and no role already recorded. A unitless value is never stamped: no
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
308
330
  // size transform claims one since #52, and stamping a ratio as text would still
309
331
  // be a claim the source never made.
310
332
  //
@@ -312,14 +334,14 @@ function classifyTextUnits(node) {
312
334
  // unresolvable reference in place for Style Dictionary to report, so the graph
313
335
  // can hold an edge to a token that does not exist. Skip it. This is also what
314
336
  // keeps the second preprocess pass from throwing, and idempotency with it.
315
- function applyTextRoleGraph(node, typographic) {
337
+ function applyTextRoleGraph(node, typographic, types) {
316
338
  for (const path of typographic) {
317
339
  let target = node;
318
340
  for (const segment of path.split('.')) {
319
341
  target = target && typeof target === 'object' ? target[segment] : undefined;
320
342
  }
321
343
  if (!target || typeof target !== 'object' || !('$value' in target)) continue;
322
- if (target.$type !== 'dimension') continue;
344
+ if (types[path] !== 'dimension') continue;
323
345
  if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
324
346
  target.$extensions ??= {};
325
347
  target.$extensions[EXT_NS] ??= {};
@@ -334,11 +356,17 @@ export function preprocess(dict) {
334
356
  // Read from the UNRESOLVED dict, before resolveInPlace flattens the aliases
335
357
  // the graph is made of.
336
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);
337
368
  const out = hoistDualNodes(
338
- applyTextRoleGraph(
339
- classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
340
- typographic,
341
- ),
369
+ applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
342
370
  collisions,
343
371
  );
344
372
  if (collisions.length) {