@apollion-dsi/tokens 4.0.0 → 4.2.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.
@@ -1,64 +1,56 @@
1
1
  /**
2
- * CSS renderer — emit Foundation tokens as CSS custom properties.
2
+ * CSS renderer — project the token IR to CSS custom properties (the resolved
3
+ * consumer surface).
3
4
  *
4
- * Format: `--apollion-{layer}-{role}-{variant}` per ADR-006 §3.3.
5
+ * Format: `--apollion-{group}-{...path}-{token}` per ADR-006 §3.3. References
6
+ * are resolved to literal values and the primitives layer is omitted — CSS
7
+ * stays fully resolved (JSON is the SSOT surface). Nested groups (border/font/
8
+ * shadow) flatten into dashed paths. Color tokens additionally get `@property`
9
+ * declarations (tech-radar R3) wrapped in `@supports`.
5
10
  *
6
- * **tech-radar R3:** also emits `@property` declarations for typed tokens
7
- * (color/length) — enables native CSS transitions (View Transitions API
8
- * friendly). `@property` is browser-supported 84%+ mid-2024; wrapped in
9
- * `@supports` for graceful degradation.
11
+ * Output is deterministic: groups/tokens iterate in IR insertion order.
10
12
  *
11
- * Output is deterministic: keys iterated in insertion order from the
12
- * FoundationLayer object; no timestamps, no run-id.
13
- *
14
- * @see ADR-006 §3.3 + §3.10 + tech-radar R3
15
- * @see PRD-002 §S5
13
+ * @see ../ir.ts (source of truth) · ADR-006 §3.3 + §3.10 · tech-radar R3
16
14
  */
17
15
 
18
- import type { FoundationLayer } from '@apollion-dsi/core/themes/foundation';
19
-
20
- import type { Variant } from '../config-schema';
16
+ import type { IRDocument, IRGroup } from '../ir';
17
+ import { isToken, kebab, rawValue } from '../ir';
21
18
 
22
19
  const HEADER_LINE = '/* Generated by apollion-tokens build — DO NOT EDIT. See apollion.config.mjs. */';
23
20
 
24
- export function renderCss(foundation: FoundationLayer, variant: Variant): string {
25
- const lines: string[] = [HEADER_LINE, ''];
26
-
27
- // Variant marker as a comment (NOT a var — keep cascade clean).
28
- lines.push(
29
- `/* Variant: brand=${variant.brand} mode=${variant.mode} surface=${variant.surface} dimension=${variant.dimension} */`,
30
- '',
31
- ':root {',
32
- );
33
-
34
- // bg layer
35
- for (const [key, value] of Object.entries(foundation.bg)) {
36
- lines.push(` --apollion-bg-${key}: ${value};`);
21
+ function emitVars(group: IRGroup, prefix: string[], lines: string[]): void {
22
+ for (const [key, node] of Object.entries(group)) {
23
+ const path = [...prefix, kebab(key)];
24
+ if (isToken(node)) lines.push(` --apollion-${path.join('-')}: ${rawValue(node.value)};`);
25
+ else emitVars(node, path, lines);
37
26
  }
27
+ }
38
28
 
39
- // text layer
40
- for (const [key, value] of Object.entries(foundation.text)) {
41
- const kebab = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
42
- lines.push(` --apollion-text-${kebab}: ${value};`);
29
+ function emitProperties(group: IRGroup, prefix: string[], lines: string[]): void {
30
+ for (const [key, node] of Object.entries(group)) {
31
+ const path = [...prefix, kebab(key)];
32
+ if (!isToken(node)) {
33
+ emitProperties(node, path, lines);
34
+ } else if (node.type === 'color') {
35
+ lines.push(
36
+ ` @property --apollion-${path.join('-')} { syntax: '<color>'; inherits: true; initial-value: ${rawValue(node.value)}; }`,
37
+ );
38
+ }
43
39
  }
40
+ }
44
41
 
45
- // spacing layer
46
- for (const [key, value] of Object.entries(foundation.spacing)) {
47
- lines.push(` --apollion-spacing-${key}: ${value};`);
48
- }
42
+ export function renderCss(ir: IRDocument): string {
43
+ const { brand, mode, surface, dimension } = ir.meta;
44
+ const lines: string[] = [HEADER_LINE, ''];
49
45
 
46
+ // Variant marker as a comment (NOT a var — keep cascade clean).
47
+ lines.push(`/* Variant: brand=${brand} mode=${mode} surface=${surface} dimension=${dimension} */`, '', ':root {');
48
+ emitVars(ir.groups, [], lines);
50
49
  lines.push('}', '');
51
50
 
52
- // @property declarations for color tokens — opt-in via @supports
51
+ // @property declarations for color tokens — opt-in via @supports.
53
52
  lines.push('@supports (background: paint(squircle)) or (color: oklch(0% 0 0)) {');
54
- for (const [key, value] of Object.entries(foundation.bg)) {
55
- lines.push(` @property --apollion-bg-${key} { syntax: '<color>'; inherits: true; initial-value: ${value}; }`);
56
- }
57
- for (const key of Object.keys(foundation.text)) {
58
- const kebab = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
59
- const value = (foundation.text as unknown as Record<string, string>)[key];
60
- lines.push(` @property --apollion-text-${kebab} { syntax: '<color>'; inherits: true; initial-value: ${value}; }`);
61
- }
53
+ emitProperties(ir.groups, [], lines);
62
54
  lines.push('}', '');
63
55
 
64
56
  return lines.join('\n');
@@ -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,81 +1,41 @@
1
1
  /**
2
- * JSON renderer — emit Foundation tokens as 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
- * **Spec:** [Design Tokens Community Group Format Module](https://design-tokens.github.io/community-group/format/)
5
- * (W3C draft track, mid-2024).
6
+ * **Spec:** [Design Tokens Format Module 2025.10](https://www.designtokens.org/TR/drafts/format/)
7
+ * (DTCG / W3C Community Group draft track).
6
8
  *
7
- * Each token is a leaf with `$value` + `$type`. Groups nest naturally:
8
- * ```json
9
- * { "bg": { "primary": { "$value": "#003750", "$type": "color" } } }
10
- * ```
9
+ * Pure projection of the IR (`ir.ts`) via the shared `dtcg-project` mapping:
10
+ * - `primitives` → sibling top-level groups (`color` / `space` / `border-width`).
11
+ * - colour/dimension `{ colorSpace, components, hex, alpha? }` / `{ value, unit }`.
12
+ * - references → DTCG aliases, e.g. `"$value": "{color.primary.base}"`.
13
+ * - composites/scales (S4) → `fontFamily`/`fontWeight`/`number`/`strokeStyle`
14
+ * primitives and the `shadow` composite `{ color, offsetX, offsetY, blur, spread, inset? }`.
11
15
  *
12
- * Enables interop with Tokens Studio (Figma), Style Dictionary downstream,
13
- * Specify, and any tool consuming the DTCG spec.
16
+ * Non-DTCG values travel in the document `$extensions` (`com.apollion.*`).
14
17
  *
15
- * @see tech-radar R1 (DTCG compliance)
16
- * @see ADR-006 §3.10 + PRD-002 §S5
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 { FoundationLayer } from '@apollion-dsi/core/themes/foundation';
20
-
21
- import type { Variant } from '../config-schema';
22
-
23
- interface DtcgToken<T extends string = string> {
24
- $value: string;
25
- $type: T;
26
- }
27
-
28
- interface DtcgGroup {
29
- [key: string]: DtcgToken | DtcgGroup;
30
- }
31
-
32
- export interface DtcgDocument {
33
- $description: string;
34
- bg: DtcgGroup;
35
- text: DtcgGroup;
36
- spacing: DtcgGroup;
37
- /** Variant metadata — non-standard extension under DTCG `$extensions` namespace. */
38
- $extensions: {
39
- 'com.apollion.variant': {
40
- brand: string;
41
- mode: string;
42
- surface: string;
43
- dimension: string;
44
- };
45
- };
46
- }
47
-
48
- function toColorGroup(layer: Record<string, string>): DtcgGroup {
49
- const out: DtcgGroup = {};
50
- for (const [key, value] of Object.entries(layer)) {
51
- const kebab = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
52
- out[kebab] = { $value: value, $type: 'color' };
53
- }
54
- return out;
55
- }
26
+ import { projectGroup } from './dtcg-project';
56
27
 
57
- function toDimensionGroup(layer: Record<string, string>): DtcgGroup {
58
- const out: DtcgGroup = {};
59
- for (const [key, value] of Object.entries(layer)) {
60
- out[key] = { $value: value, $type: 'dimension' };
61
- }
62
- return out;
63
- }
28
+ import type { IRDocument } from '../ir';
64
29
 
65
- export function renderJson(foundation: FoundationLayer, variant: Variant): string {
66
- const doc: DtcgDocument = {
67
- $description: `Apollion DS tokens DTCG v1. Variant: ${variant.brand}/${variant.mode}/${variant.surface}/${variant.dimension}.`,
68
- bg: toColorGroup(foundation.bg as unknown as Record<string, string>),
69
- text: toColorGroup(foundation.text as unknown as Record<string, string>),
70
- spacing: toDimensionGroup(foundation.spacing as unknown as Record<string, string>),
71
- $extensions: {
72
- 'com.apollion.variant': {
73
- brand: variant.brand,
74
- mode: variant.mode,
75
- surface: variant.surface,
76
- dimension: variant.dimension,
77
- },
78
- },
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.
34
+ const doc = {
35
+ $description: ir.description,
36
+ ...projectGroup(ir.primitives),
37
+ ...projectGroup(ir.groups),
38
+ $extensions: ir.extensions,
79
39
  };
80
40
  return `${JSON.stringify(doc, null, 2)}\n`;
81
41
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Resolver renderer (R5) — project the IR slices to a DTCG **Resolver** manifest
3
+ * plus the reference-closed set documents it points at.
4
+ *
5
+ * **Spec:** DTCG Resolver draft (Design Tokens Community Group — Resolver /
6
+ * "modes" module on the 2025.10 draft track). A resolver names a set of token
7
+ * files (`sets`) and a set of `modifiers` (enumerated axes with a `default`); a
8
+ * consumer composes the chosen modifier values into a single resolved token
9
+ * tree. This replaces the old N-file per-variant JSON explosion with one
10
+ * manifest + O(brand·mode·surface + dimension + 1) sets.
11
+ *
12
+ * **S5 mode caveat:** `mode` is emitted as a real modifier (values + default)
13
+ * even though palette generation is currently mode-agnostic (dark == light).
14
+ * The topology is therefore correct ahead of S6 wiring real dark-mode logic.
15
+ *
16
+ * Set documents are projected from `IRSlice`s via the shared `dtcg-project`
17
+ * mapping, so a composed (base + color + spacing) tree is byte-equivalent to the
18
+ * old full per-variant document (asserted in `resolver.test.ts`).
19
+ *
20
+ * @see ../ir.ts (slices) · ./dtcg-project.ts · ../set-plan.ts
21
+ * @see https://www.designtokens.org/TR/ (Resolver draft)
22
+ */
23
+
24
+ import { projectGroup } from './dtcg-project';
25
+
26
+ import type { ApollionConfig } from '../config-schema';
27
+ import type { IRSlice } from '../ir';
28
+ import type { SetPlan } from '../set-plan';
29
+
30
+ /** A DTCG modifier: an enumerated axis with a default value. */
31
+ interface ResolverModifier {
32
+ name: string;
33
+ values: string[];
34
+ default: string;
35
+ }
36
+
37
+ /** A DTCG set reference: a named token document at `source`. */
38
+ interface ResolverSet {
39
+ name: string;
40
+ source: string;
41
+ }
42
+
43
+ interface ResolverManifest {
44
+ name: string;
45
+ description: string;
46
+ sets: ResolverSet[];
47
+ modifiers: ResolverModifier[];
48
+ $extensions: Record<string, unknown>;
49
+ }
50
+
51
+ /** Render one IR slice (base/color/spacing) to a DTCG set JSON document. */
52
+ function renderSet(slice: IRSlice, description: string): string {
53
+ const doc: Record<string, unknown> = {
54
+ $description: description,
55
+ ...projectGroup(slice.groups),
56
+ };
57
+ if (Object.keys(slice.extensions).length > 0) doc.$extensions = slice.extensions;
58
+ return `${JSON.stringify(doc, null, 2)}\n`;
59
+ }
60
+
61
+ /** Axis-invariant base set (border + font). */
62
+ export function renderBaseSet(slice: IRSlice): string {
63
+ return renderSet(slice, 'Apollion DS — base set (axis-invariant: border + font). DTCG 2025.10.');
64
+ }
65
+
66
+ /** Colour set for one (brand × mode × surface). */
67
+ export function renderColorSet(slice: IRSlice, brand: string, mode: string, surface: string): string {
68
+ return renderSet(slice, `Apollion DS — colour set. brand=${brand} mode=${mode} surface=${surface}. DTCG 2025.10.`);
69
+ }
70
+
71
+ /** Spacing set for one dimension. */
72
+ export function renderSpacingSet(slice: IRSlice, dimension: string): string {
73
+ return renderSet(slice, `Apollion DS — spacing set. dimension=${dimension}. DTCG 2025.10.`);
74
+ }
75
+
76
+ /**
77
+ * Render the Resolver manifest (`resolver.json`).
78
+ *
79
+ * Modifiers enumerate the four axes (brand/mode/surface/dimension) with the
80
+ * first encountered value as `default`. `sets` lists every concrete set file.
81
+ * The `$extensions['com.apollion.resolver']` block documents the composition
82
+ * rule — which sets to merge for a chosen modifier tuple — since the draft's
83
+ * apply-by-modifier binding is still settling; this keeps the manifest
84
+ * self-describing for our own consumers.
85
+ */
86
+ export function renderResolver(config: ApollionConfig, plan: SetPlan): string {
87
+ const { modifiers } = plan;
88
+
89
+ const modifierList: ResolverModifier[] = [
90
+ { name: 'brand', values: [...modifiers.brand], default: modifiers.brand[0] },
91
+ { name: 'mode', values: [...modifiers.mode], default: modifiers.mode[0] },
92
+ { name: 'surface', values: [...modifiers.surface], default: modifiers.surface[0] },
93
+ { name: 'dimension', values: [...modifiers.dimension], default: modifiers.dimension[0] },
94
+ ];
95
+
96
+ const sets: ResolverSet[] = [
97
+ { name: plan.base.name, source: plan.base.file },
98
+ ...plan.spacing.map((s) => ({ name: s.name, source: s.file })),
99
+ ...plan.color.map((c) => ({ name: c.name, source: c.file })),
100
+ ];
101
+
102
+ const manifest: ResolverManifest = {
103
+ name: 'apollion-tokens',
104
+ description:
105
+ 'Apollion DS token resolver — DTCG 2025.10. Compose the base set with the ' +
106
+ 'colour set for the chosen brand/mode/surface and the spacing set for the ' +
107
+ 'chosen dimension. NOTE: mode is a topology placeholder in this release ' +
108
+ '(dark resolves identically to light until S6).',
109
+ sets,
110
+ modifiers: modifierList,
111
+ $extensions: {
112
+ 'com.apollion.resolver': {
113
+ // How to compose a final token tree for a chosen modifier tuple.
114
+ composition: {
115
+ always: [plan.base.name],
116
+ byModifier: {
117
+ // colour set name = `<brand>.color.<mode>.<surface>`
118
+ color: '${brand}.color.${mode}.${surface}',
119
+ // spacing set name = `spacing.<dimension>`
120
+ spacing: 'spacing.${dimension}',
121
+ },
122
+ },
123
+ modeIsPlaceholder: true,
124
+ },
125
+ },
126
+ };
127
+
128
+ return `${JSON.stringify(manifest, null, 2)}\n`;
129
+ }
@@ -1,46 +1,45 @@
1
1
  /**
2
- * TypeScript renderer — emit Foundation tokens as `as const` literal.
2
+ * TypeScript renderer — project the token IR to an `as const` literal (the
3
+ * resolved consumer surface).
3
4
  *
4
- * **tech-radar R4:** consumer importing the generated `.d.ts` gets literal
5
- * types (e.g. `tokens.bg.primary: 'oklch(...)'` not `string`) — autocomplete
6
- * + compile-time invariant checks.
5
+ * Consumers importing the generated `.d.ts` get literal types (e.g.
6
+ * `tokens.bg.primary: '#003750'` not `string`) — autocomplete + compile-time
7
+ * invariant checks (tech-radar R4). References resolve to literal values, the
8
+ * primitives layer is omitted, and nested groups (border/font/shadow) nest as
9
+ * nested objects.
7
10
  *
8
- * @see tech-radar R4
9
- * @see ADR-006 §3.10 + PRD-002 §S5
11
+ * @see ../ir.ts (source of truth) · tech-radar R4 · ADR-006 §3.10
10
12
  */
11
13
 
12
- import type { FoundationLayer } from '@apollion-dsi/core/themes/foundation';
13
-
14
- import type { Variant } from '../config-schema';
14
+ import type { IRDocument, IRGroup } from '../ir';
15
+ import { isToken, rawValue } from '../ir';
15
16
 
16
17
  const HEADER_LINE = '// Generated by apollion-tokens build — DO NOT EDIT. See apollion.config.mjs.';
17
18
 
18
- function serializeRecord(record: Record<string, string>, indent: number): string {
19
- const pad = ' '.repeat(indent);
20
- const entries = Object.entries(record).map(
21
- ([key, value]) => `${pad}${JSON.stringify(key)}: ${JSON.stringify(value)},`,
22
- );
23
- return entries.join('\n');
19
+ function emitGroup(group: IRGroup, depth: number, out: string[]): void {
20
+ const pad = ' '.repeat(depth);
21
+ for (const [key, node] of Object.entries(group)) {
22
+ if (isToken(node)) {
23
+ out.push(`${pad}${JSON.stringify(key)}: ${JSON.stringify(rawValue(node.value))},`);
24
+ } else {
25
+ out.push(`${pad}${key}: {`);
26
+ emitGroup(node, depth + 1, out);
27
+ out.push(`${pad}},`);
28
+ }
29
+ }
24
30
  }
25
31
 
26
- export function renderTs(foundation: FoundationLayer, variant: Variant): string {
27
- return [
32
+ export function renderTs(ir: IRDocument): string {
33
+ const { brand, mode, surface, dimension } = ir.meta;
34
+ const out: string[] = [
28
35
  HEADER_LINE,
29
- `// Variant: brand=${variant.brand} mode=${variant.mode} surface=${variant.surface} dimension=${variant.dimension}`,
36
+ `// Variant: brand=${brand} mode=${mode} surface=${surface} dimension=${dimension}`,
30
37
  '',
31
38
  'export const tokens = {',
32
- ' bg: {',
33
- serializeRecord(foundation.bg as unknown as Record<string, string>, 4),
34
- ' },',
35
- ' text: {',
36
- serializeRecord(foundation.text as unknown as Record<string, string>, 4),
37
- ' },',
38
- ' spacing: {',
39
- serializeRecord(foundation.spacing as unknown as Record<string, string>, 4),
40
- ' },',
41
- '} as const;',
42
- '',
43
- 'export type Tokens = typeof tokens;',
44
- '',
45
- ].join('\n');
39
+ ];
40
+
41
+ emitGroup(ir.groups, 1, out);
42
+
43
+ out.push('} as const;', '', 'export type Tokens = typeof tokens;', '');
44
+ return out.join('\n');
46
45
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Set topology planner (R5) — the single source of truth for which Resolver
3
+ * sets exist, their file paths, and the modifier enumerations.
4
+ *
5
+ * Both `build.ts` (which writes the set files) and `resolver.ts` (which writes
6
+ * the manifest pointing at them) derive from this plan, so set names can never
7
+ * drift between the two. Derived purely from the expanded variants of a config.
8
+ *
9
+ * **Set topology** (each set is reference-closed, see `ir.ts` slices):
10
+ * - `base` — 1 file, axis-invariant (border + font).
11
+ * - `spacing.<dimension>` — one per dimension.
12
+ * - `<brand>.color.<mode>.<surface>` — one per (brand × mode × surface).
13
+ *
14
+ * @see ir.ts (`buildBaseIR` / `buildColorIR` / `buildSpacingIR`)
15
+ * @see renderers/resolver.ts (manifest) · build.ts (set emission)
16
+ */
17
+
18
+ import type { Dimension } from '@apollion-dsi/core/themes/dimension';
19
+
20
+ import type { ApollionConfig, Mode, Surface } from './config-schema';
21
+ import { expandVariants } from './config-schema';
22
+
23
+ /** Directory (relative to dist root) holding the emitted set files. */
24
+ export const SETS_DIR = 'json/sets';
25
+ /** Resolver manifest path (relative to dist root). */
26
+ export const RESOLVER_PATH = 'json/resolver.json';
27
+
28
+ /** A colour set keyed by its (brand × mode × surface) coordinate. */
29
+ export interface ColorSetPlan {
30
+ readonly kind: 'color';
31
+ readonly name: string;
32
+ readonly file: string;
33
+ readonly brand: string;
34
+ readonly mode: Mode;
35
+ readonly surface: Surface;
36
+ }
37
+
38
+ /** A spacing set keyed by its dimension. */
39
+ export interface SpacingSetPlan {
40
+ readonly kind: 'spacing';
41
+ readonly name: string;
42
+ readonly file: string;
43
+ readonly dimension: Dimension;
44
+ }
45
+
46
+ /** The axis-invariant base set. */
47
+ export interface BaseSetPlan {
48
+ readonly kind: 'base';
49
+ readonly name: string;
50
+ readonly file: string;
51
+ }
52
+
53
+ export interface SetPlan {
54
+ readonly base: BaseSetPlan;
55
+ readonly spacing: readonly SpacingSetPlan[];
56
+ readonly color: readonly ColorSetPlan[];
57
+ /** Distinct modifier values (declaration/encounter order), for the manifest. */
58
+ readonly modifiers: {
59
+ readonly brand: readonly string[];
60
+ readonly mode: readonly Mode[];
61
+ readonly surface: readonly Surface[];
62
+ readonly dimension: readonly Dimension[];
63
+ };
64
+ }
65
+
66
+ /** Set source path relative to the dist root (used by the resolver manifest). */
67
+ function setFile(name: string): string {
68
+ return `${SETS_DIR}/${name}.json`;
69
+ }
70
+
71
+ /** Stable colour-set name for a coordinate: `<brand>.color.<mode>.<surface>`. */
72
+ export function colorSetName(brand: string, mode: Mode, surface: Surface): string {
73
+ return `${brand}.color.${mode}.${surface}`;
74
+ }
75
+
76
+ /** Stable spacing-set name for a dimension: `spacing.<dimension>`. */
77
+ export function spacingSetName(dimension: Dimension): string {
78
+ return `spacing.${dimension}`;
79
+ }
80
+
81
+ /** Push `value` onto `list` if absent — preserves first-encounter order. */
82
+ function pushUnique<T>(list: T[], value: T): void {
83
+ if (!list.includes(value)) list.push(value);
84
+ }
85
+
86
+ /**
87
+ * Derive the full set topology from a config. Deterministic: ordering follows
88
+ * `expandVariants` (brands alphabetical → declaration order for the rest), so
89
+ * the emitted manifest + files are byte-stable across runs.
90
+ */
91
+ export function planSets(config: ApollionConfig): SetPlan {
92
+ const variants = expandVariants(config);
93
+
94
+ const brands: string[] = [];
95
+ const modes: Mode[] = [];
96
+ const surfaces: Surface[] = [];
97
+ const dimensions: Dimension[] = [];
98
+
99
+ const colorSeen = new Set<string>();
100
+ const color: ColorSetPlan[] = [];
101
+ const spacingSeen = new Set<string>();
102
+ const spacing: SpacingSetPlan[] = [];
103
+
104
+ for (const v of variants) {
105
+ pushUnique(brands, v.brand);
106
+ pushUnique(modes, v.mode);
107
+ pushUnique(surfaces, v.surface);
108
+ pushUnique(dimensions, v.dimension);
109
+
110
+ const cName = colorSetName(v.brand, v.mode, v.surface);
111
+ if (!colorSeen.has(cName)) {
112
+ colorSeen.add(cName);
113
+ color.push({
114
+ kind: 'color',
115
+ name: cName,
116
+ file: setFile(cName),
117
+ brand: v.brand,
118
+ mode: v.mode,
119
+ surface: v.surface,
120
+ });
121
+ }
122
+
123
+ const sName = spacingSetName(v.dimension);
124
+ if (!spacingSeen.has(sName)) {
125
+ spacingSeen.add(sName);
126
+ spacing.push({ kind: 'spacing', name: sName, file: setFile(sName), dimension: v.dimension });
127
+ }
128
+ }
129
+
130
+ return {
131
+ base: { kind: 'base', name: 'base', file: setFile('base') },
132
+ spacing,
133
+ color,
134
+ modifiers: { brand: brands, mode: modes, surface: surfaces, dimension: dimensions },
135
+ };
136
+ }