@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
|
@@ -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
|
+
}
|
package/src/set-plan.ts
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token metadata registry — the SSOT for DTCG `$description` / `$deprecated`
|
|
3
|
+
* annotations (Format Module 2025.10 §"Description" + §"Deprecation").
|
|
4
|
+
*
|
|
5
|
+
* **Why a side-table (not inline on each builder):** descriptions and
|
|
6
|
+
* deprecations are editorial metadata, not derived from the foundation math.
|
|
7
|
+
* Keeping them in one keyed map lets the IR stay a pure projection of the
|
|
8
|
+
* resolved theme while `decorateMeta` (see `ir.ts`) stitches the prose on in a
|
|
9
|
+
* single post-pass — smaller diff than threading copy through every builder.
|
|
10
|
+
*
|
|
11
|
+
* **Keying:** each key is a token's dotted IR path **after** kebab-case
|
|
12
|
+
* normalization (`kebabPath`), i.e. exactly the path the JSON renderer emits in
|
|
13
|
+
* a `{...}` alias. So `bg.primary`, `border.radius.md`, `space.medium`. A dev
|
|
14
|
+
* assertion (`assertTokenMetaResolves`) fails the build/tests on any stale key
|
|
15
|
+
* that no longer maps to an IR node, so this map can never silently rot.
|
|
16
|
+
*
|
|
17
|
+
* **Deprecation values:** `true` is a bare DTCG deprecation flag; a string is a
|
|
18
|
+
* human-readable reason (DTCG allows either). Prefer the string form — it
|
|
19
|
+
* surfaces the migration path to consumers reading the JSON.
|
|
20
|
+
*
|
|
21
|
+
* @see ir.ts (`decorateMeta` post-pass + `assertTokenMetaResolves`)
|
|
22
|
+
* @see renderers/json.ts (`projectToken` emits `$description` / `$deprecated`)
|
|
23
|
+
* @see https://www.designtokens.org/TR/drafts/format/ §Description, §Deprecation
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface TokenMetaEntry {
|
|
27
|
+
/** DTCG `$description` — human-readable intent of the token. */
|
|
28
|
+
readonly description?: string;
|
|
29
|
+
/** DTCG `$deprecated` — `true` (bare flag) or a string migration reason. */
|
|
30
|
+
readonly deprecated?: boolean | string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Editorial metadata keyed by kebab-normalized dotted IR path.
|
|
35
|
+
*
|
|
36
|
+
* Starter set covers the well-known foundation roles + a couple of scale
|
|
37
|
+
* anchors; extend as tokens gain documented intent. Every key MUST resolve to
|
|
38
|
+
* a live IR node (enforced by `assertTokenMetaResolves`).
|
|
39
|
+
*/
|
|
40
|
+
export const TOKEN_META: Readonly<Record<string, TokenMetaEntry>> = {
|
|
41
|
+
// ── Foundation backgrounds (semantic surface roles) ──────────────────────
|
|
42
|
+
'bg.primary': { description: 'Primary action surface — the brand’s dominant call-to-action background.' },
|
|
43
|
+
'bg.secondary': { description: 'Secondary action surface — supporting actions paired with the primary.' },
|
|
44
|
+
'bg.danger': { description: 'Destructive / error surface — irreversible or failing actions.' },
|
|
45
|
+
'bg.success': { description: 'Positive confirmation surface — completed or healthy states.' },
|
|
46
|
+
'bg.info': {
|
|
47
|
+
description: 'Informational surface — neutral, non-blocking notices.',
|
|
48
|
+
deprecated: 'Use bg.information once the foundation role is renamed (tracked for S6).',
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// ── Foundation text-on roles ─────────────────────────────────────────────
|
|
52
|
+
'text.on-primary': { description: 'Text/icon colour with guaranteed contrast over bg.primary.' },
|
|
53
|
+
'text.on-danger': { description: 'Text/icon colour with guaranteed contrast over bg.danger.' },
|
|
54
|
+
|
|
55
|
+
// ── Spacing primitives (density-aware dimension scale) ────────────────────
|
|
56
|
+
'space.medium': { description: 'Base spacing step (1rem at normal density) — the layout rhythm unit.' },
|
|
57
|
+
'space.large': { description: 'Comfortable spacing step — section padding and card gutters.' },
|
|
58
|
+
|
|
59
|
+
// ── Spacing foundation aliases ───────────────────────────────────────────
|
|
60
|
+
'spacing.md': { description: 'Medium foundation spacing alias — references the space.medium primitive.' },
|
|
61
|
+
'spacing.lg': { description: 'Large foundation spacing alias — references the space.large primitive.' },
|
|
62
|
+
|
|
63
|
+
// ── Border scale ─────────────────────────────────────────────────────────
|
|
64
|
+
'border.radius.md': { description: 'Default card corner radius.' },
|
|
65
|
+
'border.width.thin': { description: 'Hairline border — dividers and default input outlines.' },
|
|
66
|
+
'border.width.regular': { description: 'Standard emphasis border — focused/active controls.' },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Dev assertion: every {@link TOKEN_META} key must map to a live IR node.
|
|
71
|
+
*
|
|
72
|
+
* `resolvedPaths` is the set of kebab-normalized dotted paths the IR currently
|
|
73
|
+
* emits (built by `decorateMeta`'s walk). Throws listing every stale key so a
|
|
74
|
+
* rename in the foundation can never leave dangling documentation behind.
|
|
75
|
+
*
|
|
76
|
+
* @throws if any `TOKEN_META` key is absent from `resolvedPaths`.
|
|
77
|
+
*/
|
|
78
|
+
export function assertTokenMetaResolves(resolvedPaths: ReadonlySet<string>): void {
|
|
79
|
+
const stale = Object.keys(TOKEN_META).filter((key) => !resolvedPaths.has(key));
|
|
80
|
+
if (stale.length > 0) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`token-meta: ${stale.length} stale TOKEN_META key(s) resolve to no IR node: ${stale.join(', ')}. ` +
|
|
83
|
+
`Update src/token-meta.ts to match the current IR paths.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|