@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.
- package/README.md +18 -9
- package/lib/build.cjs +6 -6
- package/lib/build.esm.js +6 -6
- package/lib/cli.mjs +8 -8
- package/package.json +4 -2
- package/src/build.ts +61 -25
- package/src/ir.ts +715 -0
- package/src/renderers/css.ts +36 -44
- package/src/renderers/dtcg-project.ts +108 -0
- package/src/renderers/json.ts +29 -69
- package/src/renderers/resolver.ts +129 -0
- package/src/renderers/ts.ts +31 -32
- package/src/set-plan.ts +136 -0
- package/src/theme-factory.ts +25 -10
- package/src/token-meta.ts +86 -0
package/src/ir.ts
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Intermediate Representation (IR) — the typed, tool-agnostic model that
|
|
3
|
+
* every renderer (JSON/CSS/TS) projects from.
|
|
4
|
+
*
|
|
5
|
+
* **Why (improve-architecture B3):** the IR centralizes token typing so a
|
|
6
|
+
* renderer is a pure projection — a new token type or output format is added in
|
|
7
|
+
* one place.
|
|
8
|
+
*
|
|
9
|
+
* **Layers:**
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* **DTCG-expressibility (S4):** values DTCG cannot model (font `letterSpacing`
|
|
20
|
+
* in `em`, `textTransform`, `fontStyle`, border `circular: 100%`) are carried in
|
|
21
|
+
* the document `$extensions` under `com.apollion.*` rather than dropped.
|
|
22
|
+
*
|
|
23
|
+
* **Byte-fidelity:** each value keeps its original resolved CSS string (`raw`)
|
|
24
|
+
* so CSS/TS projections (which resolve references) stay faithful, while JSON
|
|
25
|
+
* emits the DTCG-structured + referenced form.
|
|
26
|
+
*
|
|
27
|
+
* @see renderers/json.ts · renderers/css.ts · renderers/ts.ts
|
|
28
|
+
* @see ADR-006 §3.10 · tech-radar R1/R2 (structured) + R4 (references) + R3/B3
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { defaultInputBorder } from '@apollion-dsi/core/themes/border';
|
|
32
|
+
import { createDeth, levelValues } from '@apollion-dsi/core/themes/depth';
|
|
33
|
+
import type { Dimension } from '@apollion-dsi/core/themes/dimension';
|
|
34
|
+
import { defaultInputFont } from '@apollion-dsi/core/themes/font';
|
|
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';
|
|
38
|
+
import { converter, formatHex, parse } from 'culori';
|
|
39
|
+
|
|
40
|
+
import { createSpacing } from './builders/spacing';
|
|
41
|
+
import type { Variant } from './config-schema';
|
|
42
|
+
import type { ColorsThemeInterface } from './theme-factory';
|
|
43
|
+
import { assertTokenMetaResolves, TOKEN_META } from './token-meta';
|
|
44
|
+
|
|
45
|
+
const toOklch = converter('oklch');
|
|
46
|
+
|
|
47
|
+
export type IRTokenType = 'color' | 'dimension' | 'fontFamily' | 'fontWeight' | 'number' | 'strokeStyle' | 'shadow';
|
|
48
|
+
|
|
49
|
+
export interface IRColorValue {
|
|
50
|
+
readonly kind: 'color';
|
|
51
|
+
/** Original resolved CSS color string — preserves CSS/TS byte-fidelity. */
|
|
52
|
+
readonly raw: string;
|
|
53
|
+
/** OKLch components `[L, C, H]` — L ∈ [0,1], C ≥ 0, H in degrees. */
|
|
54
|
+
readonly components: readonly [number, number, number];
|
|
55
|
+
readonly alpha?: number;
|
|
56
|
+
/** sRGB hex fallback (normalized). */
|
|
57
|
+
readonly hex: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface IRDimensionValue {
|
|
61
|
+
readonly kind: 'dimension';
|
|
62
|
+
readonly raw: string;
|
|
63
|
+
readonly value: number;
|
|
64
|
+
readonly unit: 'px' | 'rem';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface IRFontFamilyValue {
|
|
68
|
+
readonly kind: 'fontFamily';
|
|
69
|
+
readonly raw: string;
|
|
70
|
+
readonly family: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IRFontWeightValue {
|
|
74
|
+
readonly kind: 'fontWeight';
|
|
75
|
+
readonly raw: string;
|
|
76
|
+
readonly weight: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface IRNumberValue {
|
|
80
|
+
readonly kind: 'number';
|
|
81
|
+
readonly raw: string;
|
|
82
|
+
readonly number: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface IRStrokeStyleValue {
|
|
86
|
+
readonly kind: 'strokeStyle';
|
|
87
|
+
readonly raw: string;
|
|
88
|
+
readonly style: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface IRShadowLayer {
|
|
92
|
+
readonly color: IRColorValue;
|
|
93
|
+
readonly offsetX: IRDimensionValue;
|
|
94
|
+
readonly offsetY: IRDimensionValue;
|
|
95
|
+
readonly blur: IRDimensionValue;
|
|
96
|
+
readonly spread: IRDimensionValue;
|
|
97
|
+
readonly inset: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface IRShadowValue {
|
|
101
|
+
readonly kind: 'shadow';
|
|
102
|
+
readonly raw: string;
|
|
103
|
+
readonly shadow: IRShadowLayer;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A reference to another token (DTCG alias). `resolved` carries the target's
|
|
107
|
+
* value so CSS/TS can project a concrete value without graph traversal. */
|
|
108
|
+
export interface IRReference {
|
|
109
|
+
readonly kind: 'ref';
|
|
110
|
+
readonly path: string;
|
|
111
|
+
readonly resolved: IRColorValue | IRDimensionValue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type IRValue =
|
|
115
|
+
| IRColorValue
|
|
116
|
+
| IRDimensionValue
|
|
117
|
+
| IRFontFamilyValue
|
|
118
|
+
| IRFontWeightValue
|
|
119
|
+
| IRNumberValue
|
|
120
|
+
| IRStrokeStyleValue
|
|
121
|
+
| IRShadowValue
|
|
122
|
+
| IRReference;
|
|
123
|
+
|
|
124
|
+
export interface IRToken {
|
|
125
|
+
readonly type: IRTokenType;
|
|
126
|
+
readonly value: IRValue;
|
|
127
|
+
readonly description?: string;
|
|
128
|
+
readonly deprecated?: boolean | string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface IRGroup {
|
|
132
|
+
readonly [key: string]: IRToken | IRGroup;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface IRVariantMeta {
|
|
136
|
+
readonly brand: string;
|
|
137
|
+
readonly mode: string;
|
|
138
|
+
readonly surface: string;
|
|
139
|
+
readonly dimension: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface IRDocument {
|
|
143
|
+
readonly description: string;
|
|
144
|
+
readonly meta: IRVariantMeta;
|
|
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
|
+
*/
|
|
154
|
+
readonly primitives: IRGroup;
|
|
155
|
+
/** Foundation + scale + composite layers (`bg`/`text`/`spacing`/`border`/`font`/`shadow`). */
|
|
156
|
+
readonly groups: IRGroup;
|
|
157
|
+
readonly extensions: Readonly<Record<string, unknown>>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Narrow an IR node to a token (vs a nested group). */
|
|
161
|
+
export function isToken(node: IRToken | IRGroup): node is IRToken {
|
|
162
|
+
return typeof (node as IRToken).type === 'string' && 'value' in node;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `camelCase` → `kebab-case` token-name normalization (JSON/CSS). */
|
|
166
|
+
export function kebab(key: string): string {
|
|
167
|
+
return key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Kebab-case each segment of a dotted IR path (for DTCG `{...}` aliases). */
|
|
171
|
+
export function kebabPath(path: string): string {
|
|
172
|
+
return path.split('.').map(kebab).join('.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Concrete value of an IR value — resolving references to their target. */
|
|
176
|
+
export function rawValue(value: IRValue): string {
|
|
177
|
+
return value.kind === 'ref' ? value.resolved.raw : value.raw;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function round(value: number, dp: number): number {
|
|
181
|
+
return Number(value.toFixed(dp)) || 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Build an OKLch IR color value from a resolved CSS color string. */
|
|
185
|
+
export function toColorValue(raw: string): IRColorValue {
|
|
186
|
+
const oklch = toOklch(parse(raw));
|
|
187
|
+
if (!oklch) return { kind: 'color', raw, components: [0, 0, 0], hex: '#000000' };
|
|
188
|
+
|
|
189
|
+
const components: readonly [number, number, number] = [round(oklch.l, 4), round(oklch.c, 4), round(oklch.h ?? 0, 2)];
|
|
190
|
+
const hex = formatHex(oklch) ?? '#000000';
|
|
191
|
+
|
|
192
|
+
return oklch.alpha !== undefined && oklch.alpha !== 1
|
|
193
|
+
? { kind: 'color', raw, components, alpha: round(oklch.alpha, 4), hex }
|
|
194
|
+
: { kind: 'color', raw, components, hex };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const DIMENSION_RE = /^(-?(?:\d+\.?\d*|\.\d+))(px|rem)$/;
|
|
198
|
+
|
|
199
|
+
/** Build an IR dimension value from a resolved CSS length string. */
|
|
200
|
+
export function toDimensionValue(raw: string): IRDimensionValue {
|
|
201
|
+
const match = DIMENSION_RE.exec(raw.trim());
|
|
202
|
+
if (match) return { kind: 'dimension', raw, value: Number(match[1]), unit: match[2] as 'px' | 'rem' };
|
|
203
|
+
|
|
204
|
+
const value = Number.parseFloat(raw);
|
|
205
|
+
return { kind: 'dimension', raw, value: Number.isFinite(value) ? value : 0, unit: 'rem' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function colorToken(raw: string): IRToken {
|
|
209
|
+
return { type: 'color', value: toColorValue(raw) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function recordColorGroup(record: Record<string, string>): IRGroup {
|
|
213
|
+
const out: Record<string, IRToken> = {};
|
|
214
|
+
for (const [key, value] of Object.entries(record)) out[key] = colorToken(value);
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const NAMED_PALETTES = [
|
|
219
|
+
'main',
|
|
220
|
+
'opposite',
|
|
221
|
+
'complementary',
|
|
222
|
+
'primary',
|
|
223
|
+
'secondary',
|
|
224
|
+
'tertiary',
|
|
225
|
+
'success',
|
|
226
|
+
'danger',
|
|
227
|
+
'warning',
|
|
228
|
+
'information',
|
|
229
|
+
] as const;
|
|
230
|
+
|
|
231
|
+
/** Structural palette → colour primitives (`color.*`). The colour SSOT. */
|
|
232
|
+
function buildColorPrimitives(colors: ColorsThemeInterface): IRGroup {
|
|
233
|
+
const out: Record<string, IRToken | IRGroup> = {};
|
|
234
|
+
for (const name of NAMED_PALETTES) {
|
|
235
|
+
const palette = colors[name];
|
|
236
|
+
out[name] = {
|
|
237
|
+
base: colorToken(palette.base),
|
|
238
|
+
dark: colorToken(palette.dark),
|
|
239
|
+
action: colorToken(palette.action),
|
|
240
|
+
active: colorToken(palette.active),
|
|
241
|
+
light: colorToken(palette.light),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
out.grayscale = recordColorGroup(colors.grayscale);
|
|
245
|
+
out.neutral = recordColorGroup(colors.neutral);
|
|
246
|
+
out.baseDark = colorToken(colors.baseDark ?? '#000000');
|
|
247
|
+
out.baseLight = colorToken(colors.baseLight ?? '#ffffff');
|
|
248
|
+
out.deepDark = colorToken(colors.deepDark ?? '#000000');
|
|
249
|
+
out.deepLight = colorToken(colors.deepLight ?? '#ffffff');
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
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
|
+
|
|
289
|
+
/** Foundation role → primitive IR path (static mapping, verified vs core). */
|
|
290
|
+
const BG_REF: Readonly<Record<string, string>> = {
|
|
291
|
+
primary: 'color.primary.base',
|
|
292
|
+
secondary: 'color.secondary.base',
|
|
293
|
+
tertiary: 'color.tertiary.base',
|
|
294
|
+
success: 'color.success.base',
|
|
295
|
+
danger: 'color.danger.base',
|
|
296
|
+
warning: 'color.warning.base',
|
|
297
|
+
info: 'color.information.base',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const TEXT_REF: Readonly<Record<string, string>> = {
|
|
301
|
+
onPrimary: 'color.baseLight',
|
|
302
|
+
onSecondary: 'color.baseLight',
|
|
303
|
+
onTertiary: 'color.baseDark',
|
|
304
|
+
onSuccess: 'color.baseLight',
|
|
305
|
+
onDanger: 'color.baseLight',
|
|
306
|
+
onWarning: 'color.baseDark',
|
|
307
|
+
onInfo: 'color.baseLight',
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/** Navigate the structural palette by IR path (`color.x.y`). */
|
|
311
|
+
function lookupPrimitive(colors: ColorsThemeInterface, path: string): string | undefined {
|
|
312
|
+
let node: unknown = colors;
|
|
313
|
+
for (const segment of path.split('.').slice(1)) {
|
|
314
|
+
if (node && typeof node === 'object' && segment in node) {
|
|
315
|
+
node = (node as Record<string, unknown>)[segment];
|
|
316
|
+
} else {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return typeof node === 'string' ? node : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Colour layer: reference the primitive when the resolved value matches, else inline. */
|
|
324
|
+
function colorRefGroup(
|
|
325
|
+
layer: Record<string, string>,
|
|
326
|
+
colors: ColorsThemeInterface,
|
|
327
|
+
refMap: Readonly<Record<string, string>>,
|
|
328
|
+
): IRGroup {
|
|
329
|
+
const out: Record<string, IRToken> = {};
|
|
330
|
+
for (const [key, value] of Object.entries(layer)) {
|
|
331
|
+
const path = refMap[key];
|
|
332
|
+
const primitive = path ? lookupPrimitive(colors, path) : undefined;
|
|
333
|
+
out[key] =
|
|
334
|
+
path && primitive === value
|
|
335
|
+
? { type: 'color', value: { kind: 'ref', path, resolved: toColorValue(value) } }
|
|
336
|
+
: colorToken(value);
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function dimensionGroup(layer: Record<string, string>): IRGroup {
|
|
342
|
+
const out: Record<string, IRToken> = {};
|
|
343
|
+
for (const [key, value] of Object.entries(layer)) {
|
|
344
|
+
out[key] = { type: 'dimension', value: toDimensionValue(value) };
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
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> } {
|
|
420
|
+
const radius: Record<string, IRToken> = {};
|
|
421
|
+
const extRadius: Record<string, string> = {};
|
|
422
|
+
for (const [key, value] of Object.entries(defaultInputBorder.borderRadius)) {
|
|
423
|
+
if (DIMENSION_RE.test(value)) radius[key] = { type: 'dimension', value: toDimensionValue(value) };
|
|
424
|
+
else extRadius[key] = value;
|
|
425
|
+
}
|
|
426
|
+
const width = dimensionRefGroup(
|
|
427
|
+
defaultInputBorder.borderWidth as unknown as Record<string, string>,
|
|
428
|
+
primitives,
|
|
429
|
+
BORDER_WIDTH_REF,
|
|
430
|
+
);
|
|
431
|
+
const style: Record<string, IRToken> = {};
|
|
432
|
+
for (const [key, value] of Object.entries(defaultInputBorder.borderStyle)) {
|
|
433
|
+
style[key] = { type: 'strokeStyle', value: { kind: 'strokeStyle', raw: value, style: value } };
|
|
434
|
+
}
|
|
435
|
+
return { group: { radius, width, style }, extensions: extRadius };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Font scales: family/size/weight/lineHeight as DTCG types; non-DTCG bits
|
|
439
|
+
* (letterSpacing/textTransform/fontStyle) go to `$extensions`. */
|
|
440
|
+
function buildFont(): { group: IRGroup; extensions: Record<string, unknown> } {
|
|
441
|
+
const family: Record<string, IRToken> = {};
|
|
442
|
+
for (const [key, value] of Object.entries(defaultInputFont.fontFamily)) {
|
|
443
|
+
const clean = value.replace(/;\s*$/, '').trim();
|
|
444
|
+
family[key] = { type: 'fontFamily', value: { kind: 'fontFamily', raw: clean, family: clean } };
|
|
445
|
+
}
|
|
446
|
+
const size: Record<string, IRToken> = {};
|
|
447
|
+
for (const [key, value] of Object.entries(defaultInputFont.fontSize)) {
|
|
448
|
+
size[key] = { type: 'dimension', value: { kind: 'dimension', raw: `${value}px`, value, unit: 'px' } };
|
|
449
|
+
}
|
|
450
|
+
const weight: Record<string, IRToken> = {};
|
|
451
|
+
for (const [key, value] of Object.entries(defaultInputFont.fontWeight)) {
|
|
452
|
+
weight[key] = { type: 'fontWeight', value: { kind: 'fontWeight', raw: `${value}`, weight: value } };
|
|
453
|
+
}
|
|
454
|
+
const lineHeight: Record<string, IRToken> = {};
|
|
455
|
+
for (const [key, value] of Object.entries(defaultInputFont.lineHeight)) {
|
|
456
|
+
lineHeight[key] = { type: 'number', value: { kind: 'number', raw: `${value}`, number: value } };
|
|
457
|
+
}
|
|
458
|
+
const extensions = {
|
|
459
|
+
letterSpacing: defaultInputFont.letterSpacing,
|
|
460
|
+
textTransform: defaultInputFont.textTransform,
|
|
461
|
+
fontStyle: defaultInputFont.fontStyle,
|
|
462
|
+
};
|
|
463
|
+
return { group: { family, size, weight, lineHeight }, extensions };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const SHADOW_COLOR_RE = /rgba?\([^)]*\)|#[0-9a-fA-F]+|[a-zA-Z]+$/;
|
|
467
|
+
|
|
468
|
+
/** Parse a CSS box-shadow string into a DTCG-shaped shadow layer. */
|
|
469
|
+
function parseShadow(css: string): IRShadowValue {
|
|
470
|
+
let body = css.trim();
|
|
471
|
+
const inset = body.startsWith('inset');
|
|
472
|
+
if (inset) body = body.slice('inset'.length).trim();
|
|
473
|
+
|
|
474
|
+
const colorMatch = body.match(SHADOW_COLOR_RE);
|
|
475
|
+
const colorStr = colorMatch ? colorMatch[0] : '#000000';
|
|
476
|
+
const lengthsPart = colorMatch ? body.slice(0, colorMatch.index).trim() : body;
|
|
477
|
+
const lengths = lengthsPart.split(/\s+/).filter(Boolean);
|
|
478
|
+
const dim = (i: number): IRDimensionValue => toDimensionValue(lengths[i] ?? '0px');
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
kind: 'shadow',
|
|
482
|
+
raw: css,
|
|
483
|
+
shadow: { color: toColorValue(colorStr), offsetX: dim(0), offsetY: dim(1), blur: dim(2), spread: dim(3), inset },
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
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
|
+
*/
|
|
499
|
+
function buildShadow(colors: ColorsThemeInterface): IRGroup {
|
|
500
|
+
const depth = createDeth(colors as never);
|
|
501
|
+
const out: Record<string, IRToken> = {};
|
|
502
|
+
for (const level of Object.keys(levelValues)) {
|
|
503
|
+
out[level] = { type: 'shadow', value: parseShadow(depth(level as never)) };
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
|
|
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
|
+
*/
|
|
680
|
+
export function buildIR(foundation: FoundationLayer, colors: ColorsThemeInterface, variant: Variant): IRDocument {
|
|
681
|
+
const meta: IRVariantMeta = {
|
|
682
|
+
brand: variant.brand,
|
|
683
|
+
mode: variant.mode,
|
|
684
|
+
surface: variant.surface,
|
|
685
|
+
dimension: variant.dimension,
|
|
686
|
+
};
|
|
687
|
+
const primitives = buildPrimitives(colors, variant.dimension);
|
|
688
|
+
const border = buildBorder(primitives);
|
|
689
|
+
const font = buildFont();
|
|
690
|
+
|
|
691
|
+
const extensions: Record<string, unknown> = { 'com.apollion.variant': meta, 'com.apollion.font': font.extensions };
|
|
692
|
+
if (Object.keys(border.extensions).length > 0) {
|
|
693
|
+
extensions['com.apollion.border'] = { radius: border.extensions };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const doc: IRDocument = {
|
|
697
|
+
description: `Apollion DS tokens — DTCG 2025.10. Variant: ${meta.brand}/${meta.mode}/${meta.surface}/${meta.dimension}.`,
|
|
698
|
+
meta,
|
|
699
|
+
primitives,
|
|
700
|
+
groups: {
|
|
701
|
+
bg: colorRefGroup(foundation.bg as unknown as Record<string, string>, colors, BG_REF),
|
|
702
|
+
text: colorRefGroup(foundation.text as unknown as Record<string, string>, colors, TEXT_REF),
|
|
703
|
+
spacing: dimensionRefGroup(foundation.spacing as unknown as Record<string, string>, primitives, SPACE_REF),
|
|
704
|
+
border: border.group,
|
|
705
|
+
font: font.group,
|
|
706
|
+
shadow: buildShadow(colors),
|
|
707
|
+
},
|
|
708
|
+
extensions,
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
return decorateMeta(doc);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Explicit alias for {@link buildIR} — the full per-variant document (R5 oracle). */
|
|
715
|
+
export const buildFullIR = buildIR;
|