@apollion-dsi/tokens 4.1.0 → 4.3.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/src/ir.ts CHANGED
@@ -7,10 +7,14 @@
7
7
  * one place.
8
8
  *
9
9
  * **Layers:**
10
- * - `primitives` — the structural palette as the colour SSOT (`color.*`, R4).
11
- * - `groups` foundation aliases (`bg`/`text`) + scales (`spacing`) +
12
- * composite/scale layers (`border`/`font`/`shadow`, S4). Foundation colours
13
- * reference primitives (`{color.x.y}`); diverging values fall back to inline.
10
+ * - `primitives` — the reference SSOT, keyed by namespace: `color.*` (palette,
11
+ * R4), `space.*` (density-aware spacing, S5), `border-width.*` (S5).
12
+ * - `groups` — foundation aliases (`bg`/`text`/`spacing`) + composite/scale
13
+ * layers (`border`/`font`/`shadow`, S4). Foundation colours reference colour
14
+ * primitives (`{color.x.y}`); `spacing` + `border.width` reference dimension
15
+ * primitives (`{space.medium}` / `{border-width.thin}`) — built per-variant
16
+ * with the same multiplier so refs match under every dimension; diverging
17
+ * values fall back to inline.
14
18
  *
15
19
  * **DTCG-expressibility (S4):** values DTCG cannot model (font `letterSpacing`
16
20
  * in `em`, `textTransform`, `fontStyle`, border `circular: 100%`) are carried in
@@ -26,12 +30,17 @@
26
30
 
27
31
  import { defaultInputBorder } from '@apollion-dsi/core/themes/border';
28
32
  import { createDeth, levelValues } from '@apollion-dsi/core/themes/depth';
33
+ import type { Dimension } from '@apollion-dsi/core/themes/dimension';
29
34
  import { defaultInputFont } from '@apollion-dsi/core/themes/font';
30
35
  import type { FoundationLayer } from '@apollion-dsi/core/themes/foundation';
36
+ import type { SpacingAlias } from '@apollion-dsi/core/themes/spacing';
37
+ import { defaultInputSpacing } from '@apollion-dsi/core/themes/spacing';
31
38
  import { converter, formatHex, parse } from 'culori';
32
39
 
40
+ import { createSpacing } from './builders/spacing';
33
41
  import type { Variant } from './config-schema';
34
42
  import type { ColorsThemeInterface } from './theme-factory';
43
+ import { assertTokenMetaResolves, TOKEN_META } from './token-meta';
35
44
 
36
45
  const toOklch = converter('oklch');
37
46
 
@@ -133,7 +142,15 @@ export interface IRVariantMeta {
133
142
  export interface IRDocument {
134
143
  readonly description: string;
135
144
  readonly meta: IRVariantMeta;
136
- /** Colour SSOT — structural palette primitives (`color.*`). */
145
+ /**
146
+ * Reference SSOT layers, keyed by primitive namespace. Each top-level key
147
+ * projects to a sibling JSON group (`color`, `space`, `border-width`):
148
+ * - `color` — structural colour palette (`color.*`), R4.
149
+ * - `space` — density-aware spacing scale (`space.*`), S5 dimension refs.
150
+ * - `borderWidth` — border-width scale (`border-width.*`), S5 dimension refs.
151
+ *
152
+ * CSS/TS surfaces omit this whole layer (they emit fully-resolved values).
153
+ */
137
154
  readonly primitives: IRGroup;
138
155
  /** Foundation + scale + composite layers (`bg`/`text`/`spacing`/`border`/`font`/`shadow`). */
139
156
  readonly groups: IRGroup;
@@ -211,8 +228,8 @@ const NAMED_PALETTES = [
211
228
  'information',
212
229
  ] as const;
213
230
 
214
- /** Structural palette → colour primitives (`color.*`). The SSOT layer. */
215
- function buildPrimitives(colors: ColorsThemeInterface): IRGroup {
231
+ /** Structural palette → colour primitives (`color.*`). The colour SSOT. */
232
+ function buildColorPrimitives(colors: ColorsThemeInterface): IRGroup {
216
233
  const out: Record<string, IRToken | IRGroup> = {};
217
234
  for (const name of NAMED_PALETTES) {
218
235
  const palette = colors[name];
@@ -233,6 +250,42 @@ function buildPrimitives(colors: ColorsThemeInterface): IRGroup {
233
250
  return out;
234
251
  }
235
252
 
253
+ /**
254
+ * Density-aware spacing primitives (`space.*`) — the dimension-reference SSOT.
255
+ *
256
+ * CRITICAL: built with the SAME `(input, dimension)` the foundation used
257
+ * (`createSpacing`), so a foundation spacing value (e.g. `spacing.md = 1rem`
258
+ * at normal density) is byte-equal to its primitive (`space.medium`) and the
259
+ * reference matches under compact/normal/spacious alike. Build with a different
260
+ * multiplier and every `spacing.*` would silently fall back to inline.
261
+ */
262
+ function buildSpacePrimitives(dimension: Dimension): IRGroup {
263
+ const spacing = createSpacing(defaultInputSpacing, dimension);
264
+ const out: Record<string, IRToken> = {};
265
+ for (const alias of Object.keys(defaultInputSpacing.alias) as SpacingAlias[]) {
266
+ out[alias] = { type: 'dimension', value: toDimensionValue(spacing(alias)) };
267
+ }
268
+ return out;
269
+ }
270
+
271
+ /** Border-width primitives (`border-width.*`) — the border-width reference SSOT. */
272
+ function buildBorderWidthPrimitives(): IRGroup {
273
+ const out: Record<string, IRToken> = {};
274
+ for (const [key, value] of Object.entries(defaultInputBorder.borderWidth)) {
275
+ out[key] = { type: 'dimension', value: toDimensionValue(value) };
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /** All reference SSOT primitives, keyed by namespace (`color`/`space`/`borderWidth`). */
281
+ function buildPrimitives(colors: ColorsThemeInterface, dimension: Dimension): IRGroup {
282
+ return {
283
+ color: buildColorPrimitives(colors),
284
+ space: buildSpacePrimitives(dimension),
285
+ borderWidth: buildBorderWidthPrimitives(),
286
+ };
287
+ }
288
+
236
289
  /** Foundation role → primitive IR path (static mapping, verified vs core). */
237
290
  const BG_REF: Readonly<Record<string, string>> = {
238
291
  primary: 'color.primary.base',
@@ -293,19 +346,88 @@ function dimensionGroup(layer: Record<string, string>): IRGroup {
293
346
  return out;
294
347
  }
295
348
 
296
- /** Border scales: px → dimension, style → strokeStyle. Non-px (e.g. `100%`)
297
- * goes to `$extensions`. */
298
- function buildBorder(): { group: IRGroup; extensions: Record<string, string> } {
349
+ /**
350
+ * Foundation spacing alias → `space.*` primitive path.
351
+ *
352
+ * Encodes the foundation→core key rename (Foundation.helpers.ts): the
353
+ * foundation exposes `xs/sm/md/lg/xl/xxl`, the core spacing scale is keyed
354
+ * `xs/small/medium/large/xl/xxl`. So `sm`→`small`, `md`→`medium`, `lg`→`large`;
355
+ * the rest are identity.
356
+ */
357
+ const SPACE_REF: Readonly<Record<string, string>> = {
358
+ xs: 'space.xs',
359
+ sm: 'space.small',
360
+ md: 'space.medium',
361
+ lg: 'space.large',
362
+ xl: 'space.xl',
363
+ xxl: 'space.xxl',
364
+ };
365
+
366
+ /** Border-width key → `border-width.*` primitive path (identity — same keys). */
367
+ const BORDER_WIDTH_REF: Readonly<Record<string, string>> = {
368
+ none: 'border-width.none',
369
+ thin: 'border-width.thin',
370
+ regular: 'border-width.regular',
371
+ great: 'border-width.great',
372
+ bold: 'border-width.bold',
373
+ };
374
+
375
+ /** Navigate a primitives namespace by IR path (`space.x` / `border-width.x`). */
376
+ function lookupDimensionPrimitive(primitives: IRGroup, path: string): string | undefined {
377
+ // The `borderWidth` namespace is keyed `borderWidth` in the IR but addressed
378
+ // as `border-width` in DTCG paths — normalize the head segment.
379
+ const segments = path.split('.');
380
+ const head = segments[0] === 'border-width' ? 'borderWidth' : segments[0];
381
+ let node: IRToken | IRGroup | undefined = primitives[head];
382
+ for (const segment of segments.slice(1)) {
383
+ if (node && !isToken(node) && segment in node) node = node[segment];
384
+ else return undefined;
385
+ }
386
+ return node && isToken(node) && node.value.kind === 'dimension' ? node.value.raw : undefined;
387
+ }
388
+
389
+ /**
390
+ * Dimension layer (spacing / border-width): reference the primitive when the
391
+ * resolved value is byte-equal, else inline. Mirrors `colorRefGroup`.
392
+ *
393
+ * Best-effort by design: a foundation value that doesn't coincide with any
394
+ * scale primitive (custom override, or a shadow offset that happens not to land
395
+ * on the scale) legitimately falls back to an inline `{ value, unit }`.
396
+ */
397
+ function dimensionRefGroup(
398
+ layer: Record<string, string>,
399
+ primitives: IRGroup,
400
+ refMap: Readonly<Record<string, string>>,
401
+ ): IRGroup {
402
+ const out: Record<string, IRToken> = {};
403
+ for (const [key, value] of Object.entries(layer)) {
404
+ const path = refMap[key];
405
+ const primitive = path ? lookupDimensionPrimitive(primitives, path) : undefined;
406
+ out[key] =
407
+ path && primitive === value
408
+ ? { type: 'dimension', value: { kind: 'ref', path, resolved: toDimensionValue(value) } }
409
+ : { type: 'dimension', value: toDimensionValue(value) };
410
+ }
411
+ return out;
412
+ }
413
+
414
+ /**
415
+ * Border scales: px → dimension, style → strokeStyle. Non-px radii (e.g.
416
+ * `100%`) go to `$extensions`. `width` references the `border-width.*`
417
+ * primitives (S5) so the scale has a single SSOT, like colour/spacing.
418
+ */
419
+ function buildBorder(primitives: IRGroup): { group: IRGroup; extensions: Record<string, string> } {
299
420
  const radius: Record<string, IRToken> = {};
300
421
  const extRadius: Record<string, string> = {};
301
422
  for (const [key, value] of Object.entries(defaultInputBorder.borderRadius)) {
302
423
  if (DIMENSION_RE.test(value)) radius[key] = { type: 'dimension', value: toDimensionValue(value) };
303
424
  else extRadius[key] = value;
304
425
  }
305
- const width: Record<string, IRToken> = {};
306
- for (const [key, value] of Object.entries(defaultInputBorder.borderWidth)) {
307
- width[key] = { type: 'dimension', value: toDimensionValue(value) };
308
- }
426
+ const width = dimensionRefGroup(
427
+ defaultInputBorder.borderWidth as unknown as Record<string, string>,
428
+ primitives,
429
+ BORDER_WIDTH_REF,
430
+ );
309
431
  const style: Record<string, IRToken> = {};
310
432
  for (const [key, value] of Object.entries(defaultInputBorder.borderStyle)) {
311
433
  style[key] = { type: 'strokeStyle', value: { kind: 'strokeStyle', raw: value, style: value } };
@@ -362,7 +484,18 @@ function parseShadow(css: string): IRShadowValue {
362
484
  };
363
485
  }
364
486
 
365
- /** Depth levels → DTCG shadow composites (geometry from core, colour resolved). */
487
+ /**
488
+ * Depth levels → DTCG shadow composites (geometry from core, colour resolved).
489
+ *
490
+ * **Dimension references (S5) — intentionally inline.** Unlike `spacing` and
491
+ * `border.width`, a shadow's `offsetX/offsetY/blur/spread` are NOT referenced to
492
+ * the `space.*` / `border-width.*` primitives. Shadow geometry is a tuned,
493
+ * continuous design space (e.g. a `3px` blur) that only ever coincides with a
494
+ * scale step by accident; emitting `{border-width.great}` for a `4px` blur would
495
+ * assert a semantic link that isn't there and would break the moment depth is
496
+ * recalibrated. They therefore stay inline `{ value, unit }` composites by
497
+ * design — the legitimate best-effort fallback (PRD S5).
498
+ */
366
499
  function buildShadow(colors: ColorsThemeInterface): IRGroup {
367
500
  const depth = createDeth(colors as never);
368
501
  const out: Record<string, IRToken> = {};
@@ -372,7 +505,178 @@ function buildShadow(colors: ColorsThemeInterface): IRGroup {
372
505
  return out;
373
506
  }
374
507
 
375
- /** Build the IR document for one resolved variant. */
508
+ /**
509
+ * Re-key a group, attaching `TOKEN_META` onto each token by its reconstructed
510
+ * dotted (kebab) path. Pure: returns a new group; the input is untouched.
511
+ *
512
+ * `prefix` is the dotted path of the parent group (kebab-normalized). Every
513
+ * visited token path is recorded in `seen` so the caller can fail on stale
514
+ * `TOKEN_META` keys (paths documented but no longer emitted).
515
+ */
516
+ function decorateGroup(group: IRGroup, prefix: string, seen: Set<string>): IRGroup {
517
+ const out: Record<string, IRToken | IRGroup> = {};
518
+ for (const [key, node] of Object.entries(group)) {
519
+ const path = prefix ? `${prefix}.${kebab(key)}` : kebab(key);
520
+ if (isToken(node)) {
521
+ seen.add(path);
522
+ const meta = TOKEN_META[path];
523
+ // Only allocate a new token when there's prose to attach — keeps the
524
+ // common (undocumented) path identity-cheap and the diff minimal.
525
+ out[key] =
526
+ meta && (meta.description !== undefined || meta.deprecated !== undefined)
527
+ ? {
528
+ ...node,
529
+ ...(meta.description !== undefined ? { description: meta.description } : {}),
530
+ ...(meta.deprecated !== undefined ? { deprecated: meta.deprecated } : {}),
531
+ }
532
+ : node;
533
+ } else {
534
+ out[key] = decorateGroup(node, path, seen);
535
+ }
536
+ }
537
+ return out;
538
+ }
539
+
540
+ /**
541
+ * Post-pass: stitch `TOKEN_META` ($description / $deprecated) onto a finished
542
+ * IR document, then assert no documentation key is stale.
543
+ *
544
+ * Walks both `primitives` and `groups` at the root — the primitives namespace
545
+ * keys (`color`/`space`/`borderWidth`) already form the head segment, so paths
546
+ * reconstruct exactly the `{...}` aliases the JSON surface emits. Running this
547
+ * once at the tail of `buildIR` is a far smaller diff than threading a path
548
+ * prefix through every builder.
549
+ *
550
+ * @throws via `assertTokenMetaResolves` if any `TOKEN_META` key resolves to no
551
+ * token in this document.
552
+ */
553
+ function decorateMeta(doc: IRDocument): IRDocument {
554
+ const seen = new Set<string>();
555
+ const primitives = decorateGroup(doc.primitives, '', seen);
556
+ const groups = decorateGroup(doc.groups, '', seen);
557
+ assertTokenMetaResolves(seen);
558
+ return { ...doc, primitives, groups };
559
+ }
560
+
561
+ // ───────────────────────────────────────────────────────────────────────────
562
+ // IR slices (R5) — composable cuts of the full document along the modifier
563
+ // axes, so the Resolver can emit one reference-closed set per axis instead of
564
+ // re-emitting the whole document per (brand × mode × surface × dimension) cell.
565
+ //
566
+ // Closure invariant: each slice contains BOTH the primitives it introduces and
567
+ // the foundation groups that reference them, so a set is self-resolving:
568
+ // - base → border (+ `border-width.*` it references) + font [axis-invariant]
569
+ // - color → `color.*` primitives + bg/text (refs) + shadow [brand×mode×surface]
570
+ // - spacing → `space.*` primitives + spacing (refs) [dimension]
571
+ // `decorateMeta` runs per-slice to attach prose; the build asserts the UNION of
572
+ // all emitted paths is meta-complete (a single set is intentionally partial).
573
+ // ───────────────────────────────────────────────────────────────────────────
574
+
575
+ /** A reference-closed cut of the IR: groups (already meta-decorated) + extensions. */
576
+ export interface IRSlice {
577
+ readonly groups: IRGroup;
578
+ readonly extensions: Readonly<Record<string, unknown>>;
579
+ }
580
+
581
+ /** Attach `TOKEN_META` to a slice's groups, recording every visited path in `seen`. */
582
+ function decorateSlice(groups: IRGroup, seen: Set<string>): IRGroup {
583
+ return decorateGroup(groups, '', seen);
584
+ }
585
+
586
+ /** Collect every kebab dotted token path emitted by a group (for meta-completeness). */
587
+ function collectPaths(group: IRGroup, prefix: string, out: Set<string>): void {
588
+ for (const [key, node] of Object.entries(group)) {
589
+ const path = prefix ? `${prefix}.${kebab(key)}` : kebab(key);
590
+ if (isToken(node)) out.add(path);
591
+ else collectPaths(node, path, out);
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Assert the UNION of all emitted set paths is `TOKEN_META`-complete.
597
+ *
598
+ * A single Resolver set is intentionally partial (e.g. the base set has no
599
+ * `bg.*`), so the stale-key guard must run over every set the build emits
600
+ * together — never per-set.
601
+ *
602
+ * @throws if any `TOKEN_META` key resolves to no token across `slices`.
603
+ */
604
+ export function assertSlicesMetaComplete(slices: readonly IRSlice[]): void {
605
+ const seen = new Set<string>();
606
+ for (const slice of slices) collectPaths(slice.groups, '', seen);
607
+ assertTokenMetaResolves(seen);
608
+ }
609
+
610
+ /**
611
+ * Axis-invariant base slice: border (radius/width/style) + font. `border.width`
612
+ * references the `border-width.*` primitives, which travel WITH this slice
613
+ * (`border-width` namespace) so the set is reference-closed. Identical for every
614
+ * brand/mode/surface/dimension.
615
+ */
616
+ export function buildBaseIR(): IRSlice {
617
+ const borderWidth = buildBorderWidthPrimitives();
618
+ const primitives: IRGroup = { borderWidth };
619
+ const border = buildBorder(primitives);
620
+ const font = buildFont();
621
+
622
+ const extensions: Record<string, unknown> = { 'com.apollion.font': font.extensions };
623
+ if (Object.keys(border.extensions).length > 0) {
624
+ extensions['com.apollion.border'] = { radius: border.extensions };
625
+ }
626
+
627
+ const seen = new Set<string>();
628
+ const groups = decorateSlice({ 'border-width': borderWidth, border: border.group, font: font.group }, seen);
629
+ return { groups, extensions };
630
+ }
631
+
632
+ /**
633
+ * Colour slice for one (brand × mode × surface): the `color.*` palette SSOT plus
634
+ * the `bg`/`text` foundation aliases (which reference it) and `shadow`. `mode` is
635
+ * a no-op placeholder in S5 (dark == light until S6) but is threaded so the set
636
+ * topology is correct ahead of time.
637
+ */
638
+ export function buildColorIR(foundation: FoundationLayer, colors: ColorsThemeInterface): IRSlice {
639
+ const color = buildColorPrimitives(colors);
640
+ const seen = new Set<string>();
641
+ const groups = decorateSlice(
642
+ {
643
+ color,
644
+ bg: colorRefGroup(foundation.bg as unknown as Record<string, string>, colors, BG_REF),
645
+ text: colorRefGroup(foundation.text as unknown as Record<string, string>, colors, TEXT_REF),
646
+ shadow: buildShadow(colors),
647
+ },
648
+ seen,
649
+ );
650
+ return { groups, extensions: {} };
651
+ }
652
+
653
+ /**
654
+ * Spacing slice for one dimension: the `space.*` density scale plus the
655
+ * `spacing` foundation aliases that reference it. Built with the dimension's
656
+ * multiplier so the refs match (see `buildSpacePrimitives`).
657
+ */
658
+ export function buildSpacingIR(foundation: FoundationLayer, dimension: Dimension): IRSlice {
659
+ const space = buildSpacePrimitives(dimension);
660
+ const primitives: IRGroup = { space };
661
+ const seen = new Set<string>();
662
+ const groups = decorateSlice(
663
+ {
664
+ space,
665
+ spacing: dimensionRefGroup(foundation.spacing as unknown as Record<string, string>, primitives, SPACE_REF),
666
+ },
667
+ seen,
668
+ );
669
+ return { groups, extensions: {} };
670
+ }
671
+
672
+ /**
673
+ * Build the FULL IR document for one resolved variant (all axes baked in).
674
+ *
675
+ * This is the per-variant document the CSS/TS surfaces project from, the
676
+ * `renderers.test.ts` helper, and the composition-equivalence oracle for the
677
+ * Resolver sets (R5): composing `buildBaseIR + buildColorIR + buildSpacingIR`
678
+ * must reproduce these exact tokens. `buildFullIR` is an explicit alias.
679
+ */
376
680
  export function buildIR(foundation: FoundationLayer, colors: ColorsThemeInterface, variant: Variant): IRDocument {
377
681
  const meta: IRVariantMeta = {
378
682
  brand: variant.brand,
@@ -380,7 +684,8 @@ export function buildIR(foundation: FoundationLayer, colors: ColorsThemeInterfac
380
684
  surface: variant.surface,
381
685
  dimension: variant.dimension,
382
686
  };
383
- const border = buildBorder();
687
+ const primitives = buildPrimitives(colors, variant.dimension);
688
+ const border = buildBorder(primitives);
384
689
  const font = buildFont();
385
690
 
386
691
  const extensions: Record<string, unknown> = { 'com.apollion.variant': meta, 'com.apollion.font': font.extensions };
@@ -388,18 +693,23 @@ export function buildIR(foundation: FoundationLayer, colors: ColorsThemeInterfac
388
693
  extensions['com.apollion.border'] = { radius: border.extensions };
389
694
  }
390
695
 
391
- return {
696
+ const doc: IRDocument = {
392
697
  description: `Apollion DS tokens — DTCG 2025.10. Variant: ${meta.brand}/${meta.mode}/${meta.surface}/${meta.dimension}.`,
393
698
  meta,
394
- primitives: buildPrimitives(colors),
699
+ primitives,
395
700
  groups: {
396
701
  bg: colorRefGroup(foundation.bg as unknown as Record<string, string>, colors, BG_REF),
397
702
  text: colorRefGroup(foundation.text as unknown as Record<string, string>, colors, TEXT_REF),
398
- spacing: dimensionGroup(foundation.spacing as unknown as Record<string, string>),
703
+ spacing: dimensionRefGroup(foundation.spacing as unknown as Record<string, string>, primitives, SPACE_REF),
399
704
  border: border.group,
400
705
  font: font.group,
401
706
  shadow: buildShadow(colors),
402
707
  },
403
708
  extensions,
404
709
  };
710
+
711
+ return decorateMeta(doc);
405
712
  }
713
+
714
+ /** Explicit alias for {@link buildIR} — the full per-variant document (R5 oracle). */
715
+ export const buildFullIR = buildIR;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared DTCG projection — the pure IR→DTCG-value mapping reused by the
3
+ * single-document JSON renderer (`json.ts`) and the Resolver set renderers
4
+ * (`resolver.ts`).
5
+ *
6
+ * Factored out so both surfaces emit byte-identical token shapes ($value /
7
+ * $type / $description / $deprecated) and a composed (base + color + spacing)
8
+ * set reproduces exactly what the full per-variant document used to emit.
9
+ *
10
+ * @see json.ts (full single-doc surface) · resolver.ts (sliced sets surface)
11
+ * @see https://www.designtokens.org/TR/drafts/format/ (DTCG 2025.10)
12
+ */
13
+
14
+ import type { IRColorValue, IRDimensionValue, IRGroup, IRShadowValue, IRToken, IRValue } from '../ir';
15
+ import { isToken, kebab, kebabPath } from '../ir';
16
+
17
+ export interface DtcgColorValue {
18
+ colorSpace: 'oklch';
19
+ components: number[];
20
+ alpha?: number;
21
+ hex: string;
22
+ }
23
+
24
+ export interface DtcgDimensionValue {
25
+ value: number;
26
+ unit: 'px' | 'rem';
27
+ }
28
+
29
+ export interface DtcgShadowValue {
30
+ color: DtcgColorValue;
31
+ offsetX: DtcgDimensionValue;
32
+ offsetY: DtcgDimensionValue;
33
+ blur: DtcgDimensionValue;
34
+ spread: DtcgDimensionValue;
35
+ inset?: boolean;
36
+ }
37
+
38
+ export type DtcgTokenValue = DtcgColorValue | DtcgDimensionValue | DtcgShadowValue | string | number;
39
+
40
+ export interface DtcgToken {
41
+ $value: DtcgTokenValue;
42
+ $type: string;
43
+ $description?: string;
44
+ $deprecated?: boolean | string;
45
+ }
46
+
47
+ export interface DtcgGroup {
48
+ [key: string]: DtcgToken | DtcgGroup;
49
+ }
50
+
51
+ function colorValue(v: IRColorValue): DtcgColorValue {
52
+ return v.alpha !== undefined
53
+ ? { colorSpace: 'oklch', components: [...v.components], alpha: v.alpha, hex: v.hex }
54
+ : { colorSpace: 'oklch', components: [...v.components], hex: v.hex };
55
+ }
56
+
57
+ function dimensionValue(v: IRDimensionValue): DtcgDimensionValue {
58
+ return { value: v.value, unit: v.unit };
59
+ }
60
+
61
+ function shadowValue(v: IRShadowValue): DtcgShadowValue {
62
+ const s = v.shadow;
63
+ const out: DtcgShadowValue = {
64
+ color: colorValue(s.color),
65
+ offsetX: dimensionValue(s.offsetX),
66
+ offsetY: dimensionValue(s.offsetY),
67
+ blur: dimensionValue(s.blur),
68
+ spread: dimensionValue(s.spread),
69
+ };
70
+ if (s.inset) out.inset = true;
71
+ return out;
72
+ }
73
+
74
+ export function projectValue(value: IRValue): DtcgTokenValue {
75
+ switch (value.kind) {
76
+ case 'ref':
77
+ return `{${kebabPath(value.path)}}`;
78
+ case 'color':
79
+ return colorValue(value);
80
+ case 'dimension':
81
+ return dimensionValue(value);
82
+ case 'fontFamily':
83
+ return value.family;
84
+ case 'fontWeight':
85
+ return value.weight;
86
+ case 'number':
87
+ return value.number;
88
+ case 'strokeStyle':
89
+ return value.style;
90
+ case 'shadow':
91
+ return shadowValue(value);
92
+ }
93
+ }
94
+
95
+ export function projectToken(token: IRToken): DtcgToken {
96
+ const out: DtcgToken = { $value: projectValue(token.value), $type: token.type };
97
+ if (token.description !== undefined) out.$description = token.description;
98
+ if (token.deprecated !== undefined) out.$deprecated = token.deprecated;
99
+ return out;
100
+ }
101
+
102
+ export function projectGroup(group: IRGroup): DtcgGroup {
103
+ const out: DtcgGroup = {};
104
+ for (const [key, node] of Object.entries(group)) {
105
+ out[kebab(key)] = isToken(node) ? projectToken(node) : projectGroup(node);
106
+ }
107
+ return out;
108
+ }
@@ -1,11 +1,13 @@
1
1
  /**
2
- * JSON renderer — project the token IR to DTCG-compliant JSON.
2
+ * JSON renderer — project the token IR to DTCG-compliant JSON (the full
3
+ * single-document surface, used by `renderers.test.ts` and as the oracle for
4
+ * resolver composition-equivalence).
3
5
  *
4
6
  * **Spec:** [Design Tokens Format Module 2025.10](https://www.designtokens.org/TR/drafts/format/)
5
7
  * (DTCG / W3C Community Group draft track).
6
8
  *
7
- * Pure projection of the IR (`ir.ts`):
8
- * - `primitives` → the `color` group (structural SSOT) of OKLch objects.
9
+ * Pure projection of the IR (`ir.ts`) via the shared `dtcg-project` mapping:
10
+ * - `primitives` → sibling top-level groups (`color` / `space` / `border-width`).
9
11
  * - colour/dimension → `{ colorSpace, components, hex, alpha? }` / `{ value, unit }`.
10
12
  * - references → DTCG aliases, e.g. `"$value": "{color.primary.base}"`.
11
13
  * - composites/scales (S4) → `fontFamily`/`fontWeight`/`number`/`strokeStyle`
@@ -13,109 +15,25 @@
13
15
  *
14
16
  * Non-DTCG values travel in the document `$extensions` (`com.apollion.*`).
15
17
  *
16
- * @see ../ir.ts (source of truth) · tech-radar R1/R2/R3/R4 · ADR-006 §3.10
18
+ * **Build surface note:** `build.ts` no longer emits this full per-variant
19
+ * document to `dist/json/` — the shipped JSON surface is the Resolver + sets
20
+ * (`resolver.ts`). This renderer remains the in-memory SSOT/oracle.
21
+ *
22
+ * @see ../ir.ts (source of truth) · ./dtcg-project.ts · ./resolver.ts
23
+ * @see tech-radar R1/R2/R3/R4 · ADR-006 §3.10
17
24
  */
18
25
 
19
- import type { IRColorValue, IRDimensionValue, IRDocument, IRGroup, IRShadowValue, IRToken, IRValue } from '../ir';
20
- import { isToken, kebab, kebabPath } from '../ir';
21
-
22
- interface DtcgColorValue {
23
- colorSpace: 'oklch';
24
- components: number[];
25
- alpha?: number;
26
- hex: string;
27
- }
28
-
29
- interface DtcgDimensionValue {
30
- value: number;
31
- unit: 'px' | 'rem';
32
- }
33
-
34
- interface DtcgShadowValue {
35
- color: DtcgColorValue;
36
- offsetX: DtcgDimensionValue;
37
- offsetY: DtcgDimensionValue;
38
- blur: DtcgDimensionValue;
39
- spread: DtcgDimensionValue;
40
- inset?: boolean;
41
- }
42
-
43
- type DtcgTokenValue = DtcgColorValue | DtcgDimensionValue | DtcgShadowValue | string | number;
44
-
45
- interface DtcgToken {
46
- $value: DtcgTokenValue;
47
- $type: string;
48
- $description?: string;
49
- $deprecated?: boolean | string;
50
- }
51
-
52
- interface DtcgGroup {
53
- [key: string]: DtcgToken | DtcgGroup;
54
- }
55
-
56
- function colorValue(v: IRColorValue): DtcgColorValue {
57
- return v.alpha !== undefined
58
- ? { colorSpace: 'oklch', components: [...v.components], alpha: v.alpha, hex: v.hex }
59
- : { colorSpace: 'oklch', components: [...v.components], hex: v.hex };
60
- }
61
-
62
- function dimensionValue(v: IRDimensionValue): DtcgDimensionValue {
63
- return { value: v.value, unit: v.unit };
64
- }
65
-
66
- function shadowValue(v: IRShadowValue): DtcgShadowValue {
67
- const s = v.shadow;
68
- const out: DtcgShadowValue = {
69
- color: colorValue(s.color),
70
- offsetX: dimensionValue(s.offsetX),
71
- offsetY: dimensionValue(s.offsetY),
72
- blur: dimensionValue(s.blur),
73
- spread: dimensionValue(s.spread),
74
- };
75
- if (s.inset) out.inset = true;
76
- return out;
77
- }
78
-
79
- function projectValue(value: IRValue): DtcgTokenValue {
80
- switch (value.kind) {
81
- case 'ref':
82
- return `{${kebabPath(value.path)}}`;
83
- case 'color':
84
- return colorValue(value);
85
- case 'dimension':
86
- return dimensionValue(value);
87
- case 'fontFamily':
88
- return value.family;
89
- case 'fontWeight':
90
- return value.weight;
91
- case 'number':
92
- return value.number;
93
- case 'strokeStyle':
94
- return value.style;
95
- case 'shadow':
96
- return shadowValue(value);
97
- }
98
- }
99
-
100
- function projectToken(token: IRToken): DtcgToken {
101
- const out: DtcgToken = { $value: projectValue(token.value), $type: token.type };
102
- if (token.description !== undefined) out.$description = token.description;
103
- if (token.deprecated !== undefined) out.$deprecated = token.deprecated;
104
- return out;
105
- }
26
+ import { projectGroup } from './dtcg-project';
106
27
 
107
- function projectGroup(group: IRGroup): DtcgGroup {
108
- const out: DtcgGroup = {};
109
- for (const [key, node] of Object.entries(group)) {
110
- out[kebab(key)] = isToken(node) ? projectToken(node) : projectGroup(node);
111
- }
112
- return out;
113
- }
28
+ import type { IRDocument } from '../ir';
114
29
 
115
30
  export function renderJson(ir: IRDocument): string {
31
+ // `primitives` is keyed by namespace (`color` / `space` / `borderWidth`);
32
+ // each projects to a sibling top-level DTCG group, so reference paths like
33
+ // `{space.medium}` resolve at the document root.
116
34
  const doc = {
117
35
  $description: ir.description,
118
- color: projectGroup(ir.primitives),
36
+ ...projectGroup(ir.primitives),
119
37
  ...projectGroup(ir.groups),
120
38
  $extensions: ir.extensions,
121
39
  };