@apollion-dsi/tokens 4.1.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 +4 -4
- package/lib/build.cjs +6 -5
- package/lib/build.esm.js +6 -5
- package/lib/cli.mjs +8 -7
- package/package.json +4 -2
- package/src/build.ts +60 -26
- package/src/ir.ts +330 -20
- package/src/renderers/dtcg-project.ts +108 -0
- package/src/renderers/json.ts +17 -99
- package/src/renderers/resolver.ts +129 -0
- package/src/set-plan.ts +136 -0
- package/src/token-meta.ts +86 -0
package/src/ir.ts
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* one place.
|
|
8
8
|
*
|
|
9
9
|
* **Layers:**
|
|
10
|
-
* - `primitives` — the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
215
|
-
function
|
|
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
|
-
/**
|
|
297
|
-
*
|
|
298
|
-
|
|
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
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/src/renderers/json.ts
CHANGED
|
@@ -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` →
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
...projectGroup(ir.primitives),
|
|
119
37
|
...projectGroup(ir.groups),
|
|
120
38
|
$extensions: ir.extensions,
|
|
121
39
|
};
|