@apollion-dsi/tokens 5.3.0 → 5.5.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Design.md renderer — resolved-values AI context per brand.
3
+ *
4
+ * The rules layer (`llms.txt`) teaches *which* token to reach
5
+ * for; this emission supplies the *resolved values* those rules reference,
6
+ * generated from the consumer's own build so it can never drift from the
7
+ * shipped theme. YAML frontmatter for machines + a markdown body for humans.
8
+ *
9
+ * One document per brand, covering every (mode × surface × dimension) variant
10
+ * of that brand. Pure projection over per-variant `IRDocument`s — no value is
11
+ * computed here; a missing value is an IR gap by contract.
12
+ *
13
+ * @see ../../../../docs/references/design-md-decision.md (adoption decision)
14
+ * @see ../ir.ts (source of truth) · ./ts.ts (sibling resolved-surface renderer)
15
+ */
16
+
17
+ import type { IRDocument, IRGroup } from '../ir';
18
+ import { isToken, rawValue } from '../ir';
19
+
20
+ const HEADER_LINE = '<!-- Generated by apollion-tokens build — DO NOT EDIT. See apollion.config.mjs. -->';
21
+
22
+ /** Flatten a resolved IR group into dot-path → raw value pairs. */
23
+ function flatten(group: IRGroup, prefix: string, out: Array<[string, string | number]>): void {
24
+ for (const [key, node] of Object.entries(group)) {
25
+ const path = prefix ? `${prefix}.${key}` : key;
26
+ if (isToken(node)) {
27
+ out.push([path, rawValue(node.value) as string | number]);
28
+ } else {
29
+ flatten(node, path, out);
30
+ }
31
+ }
32
+ }
33
+
34
+ function yamlEscape(value: string | number): string {
35
+ if (typeof value === 'number') return String(value);
36
+ // hex colors and px/rem strings are safe unquoted except when starting with
37
+ // YAML-significant characters — quote everything for uniformity/safety.
38
+ return `'${String(value).replace(/'/g, "''")}'`;
39
+ }
40
+
41
+ function variantKey(ir: IRDocument): string {
42
+ const { mode, surface, dimension } = ir.meta;
43
+ return `${mode}.${surface}.${dimension}`;
44
+ }
45
+
46
+ /**
47
+ * Render one Design.md for a brand from its variant IRDocuments.
48
+ * Deterministic: variant order is the caller's (build order), path order is
49
+ * IR insertion order — both stable for a given config.
50
+ */
51
+ export function renderDesignMd(brand: string, variants: readonly IRDocument[]): string {
52
+ if (variants.length === 0) {
53
+ throw new Error(`renderDesignMd: no variants for brand "${brand}"`);
54
+ }
55
+
56
+ // ── YAML frontmatter (machine surface) ────────────────────────────────────
57
+ const fm: string[] = ['---', `brand: '${brand}'`, 'generator: apollion-tokens', 'variants:'];
58
+ for (const ir of variants) {
59
+ const flat: Array<[string, string | number]> = [];
60
+ flatten(ir.groups, '', flat);
61
+ fm.push(` ${variantKey(ir)}:`);
62
+ for (const [path, value] of flat) {
63
+ fm.push(` ${path}: ${yamlEscape(value)}`);
64
+ }
65
+ }
66
+ fm.push('---');
67
+
68
+ // ── Markdown body (human surface) ─────────────────────────────────────────
69
+ const body: string[] = [
70
+ '',
71
+ HEADER_LINE,
72
+ '',
73
+ `# Design.md — ${brand}`,
74
+ '',
75
+ 'Resolved design-token values for this brand, one section per variant.',
76
+ 'Consumption rules (which token to reach for, layer boundaries) live in the',
77
+ 'package `llms.txt`; this file carries the values those rules reference.',
78
+ 'Regenerate with `apollion-tokens build` — never edit.',
79
+ '',
80
+ ];
81
+
82
+ for (const ir of variants) {
83
+ const { mode, surface, dimension } = ir.meta;
84
+ body.push(`## Variant \`${mode} · ${surface} · ${dimension}\``, '');
85
+ const flat: Array<[string, string | number]> = [];
86
+ flatten(ir.groups, '', flat);
87
+
88
+ // group rows by top-level namespace for readable tables
89
+ const byNs = new Map<string, Array<[string, string | number]>>();
90
+ for (const [path, value] of flat) {
91
+ const ns = path.split('.')[0] ?? path;
92
+ const rows = byNs.get(ns) ?? [];
93
+ rows.push([path, value]);
94
+ byNs.set(ns, rows);
95
+ }
96
+
97
+ for (const [ns, rows] of byNs) {
98
+ body.push(`### ${ns}`, '', '| Token | Value |', '| --- | --- |');
99
+ for (const [path, value] of rows) {
100
+ body.push(`| \`${path}\` | \`${value}\` |`);
101
+ }
102
+ body.push('');
103
+ }
104
+ }
105
+
106
+ return `${fm.join('\n')}\n${body.join('\n')}`;
107
+ }
@@ -10,9 +10,10 @@
10
10
  * manifest + O(brand·mode·surface + 2·dimension + 1) sets (spacing + font per
11
11
  * dimension).
12
12
  *
13
- * **S5 mode caveat:** `mode` is emitted as a real modifier (values + default)
14
- * even though palette generation is currently mode-agnostic (dark == light).
15
- * The topology is therefore correct ahead of S6 wiring real dark-mode logic.
13
+ * **Mode modifier:** `mode` is emitted as a real modifier (values + default),
14
+ * and since ADR-019 the dark contexts carry real values — palette generation
15
+ * derives dark through the canonical seed swap in `theme-factory.ts` (the S5
16
+ * dark == light placeholder is retired).
16
17
  *
17
18
  * Set documents are projected from `IRSlice`s via the shared `dtcg-project`
18
19
  * mapping, so a composed (base + color + spacing + font) tree is byte-equivalent
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tailwind renderer — project the token IR to a generated Tailwind preset +
3
+ * tailwind-merge extension (the Tailwind-consumer naming layer).
4
+ *
5
+ * The CSS surface already carries the values (`--apollion-*` custom
6
+ * properties); what a Tailwind stack lacks is the *naming layer* — theme keys
7
+ * pointing at those vars. Both modules are pure projections of the IR
8
+ * (generated, never hand-maintained), so preset keys cannot drift from the
9
+ * emitted CSS vars (differential-tested per group).
10
+ *
11
+ * **Alpha modifier (`/50` etc.):** Tailwind's `<alpha-value>` form needs raw
12
+ * channel triplets (`rgb(R G B / <alpha-value>)`), so opaque colour tokens map
13
+ * through `-rgb` companion vars (emitted by the CSS renderer under the same
14
+ * flag — see {@link rgbTriplet} / {@link isOpaqueColor}, the single source of
15
+ * that projection rule). Tokens that already carry alpha are excluded from the
16
+ * modifier form — mapping them through `rgb(… / <alpha-value>)` would silently
17
+ * drop their baked-in alpha (the surveyed source's silent-alpha-loss bug); the
18
+ * exclusion is noted as a comment in the emitted preset.
19
+ *
20
+ * **Scope (v1):** foundation groups only — `bg`/`text`/`tx` → `colors`,
21
+ * `spacing` → `spacing`, `border.radius`/`border.width` → `borderRadius`/
22
+ * `borderWidth`, `font.*` → `fontFamily`/`fontSize`/`fontWeight`/`lineHeight`,
23
+ * `shadow` → `boxShadow`. `border.style` has no Tailwind theme scale —
24
+ * deliberately unmapped. No dependency on the component-token tier.
25
+ *
26
+ * @see ../ir.ts (source of truth) · css.ts (`-rgb` companions) ·
27
+ * docs/plans/tailwind-preset-renderer.md (divergence-matrix row 6)
28
+ */
29
+
30
+ import { converter, parse } from 'culori';
31
+
32
+ import type { IRColorValue, IRDocument, IRGroup, IRToken } from '../ir';
33
+ import { isToken, kebab } from '../ir';
34
+
35
+ const toRgb = converter('rgb');
36
+
37
+ const HEADER_LINE = '// Generated by apollion-tokens build — DO NOT EDIT. See apollion.config.mjs.';
38
+
39
+ /** Colour payload of a token, resolving references (else undefined). */
40
+ export function colorPayload(token: IRToken): IRColorValue | undefined {
41
+ if (token.value.kind === 'color') return token.value;
42
+ if (token.value.kind === 'ref' && token.value.resolved.kind === 'color') return token.value.resolved;
43
+ return undefined;
44
+ }
45
+
46
+ /**
47
+ * Whether a colour token is opaque — eligible for the `<alpha-value>` modifier
48
+ * form via a `-rgb` companion var. Alpha-carrying tokens are excluded (the
49
+ * modifier form would silently drop their baked-in alpha).
50
+ */
51
+ export function isOpaqueColor(color: IRColorValue): boolean {
52
+ return color.alpha === undefined;
53
+ }
54
+
55
+ /**
56
+ * `R G B` channel triplet (0–255, space-separated) for a `-rgb` companion var.
57
+ * Derived from the sRGB hex fallback — renderer-local via culori (no IR change).
58
+ */
59
+ export function rgbTriplet(color: IRColorValue): string {
60
+ const rgb = toRgb(parse(color.hex));
61
+ if (!rgb) return '0 0 0';
62
+ const channel = (value: number): number => Math.min(255, Math.max(0, Math.round(value * 255)));
63
+ return `${channel(rgb.r)} ${channel(rgb.g)} ${channel(rgb.b)}`;
64
+ }
65
+
66
+ /** Flat `key → token` map of a group's tokens, kebab-dashed path keys. */
67
+ function flatTokens(group: IRGroup, prefix: string[] = []): Map<string, IRToken> {
68
+ const out = new Map<string, IRToken>();
69
+ for (const [key, node] of Object.entries(group)) {
70
+ const path = [...prefix, kebab(key)];
71
+ if (isToken(node)) out.set(path.join('-'), node);
72
+ else for (const [k, v] of flatTokens(node, path)) out.set(k, v);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** Narrow a root-level IR node to a group (missing → empty). */
78
+ function groupAt(groups: IRGroup, ...path: string[]): IRGroup {
79
+ let node: IRToken | IRGroup | undefined = groups[path[0]];
80
+ for (const segment of path.slice(1)) {
81
+ if (!node || isToken(node)) return {};
82
+ node = node[segment];
83
+ }
84
+ return node && !isToken(node) ? node : {};
85
+ }
86
+
87
+ interface ColorEntry {
88
+ readonly key: string;
89
+ readonly cssVar: string;
90
+ readonly opaque: boolean;
91
+ }
92
+
93
+ /**
94
+ * Colour entries for the preset's `colors` scale: the `bg`/`text`/`tx`
95
+ * foundation groups, keyed `<group>-<path>` (the CSS var name minus the
96
+ * `--apollion-` prefix — one mental model across surfaces).
97
+ */
98
+ function colorEntries(ir: IRDocument): ColorEntry[] {
99
+ const out: ColorEntry[] = [];
100
+ for (const groupName of ['bg', 'text', 'tx'] as const) {
101
+ for (const [key, token] of flatTokens(groupAt(ir.groups, groupName), [groupName])) {
102
+ const color = colorPayload(token);
103
+ if (!color) continue;
104
+ out.push({ key, cssVar: `--apollion-${key}`, opaque: isOpaqueColor(color) });
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** `key → var()` entries for a var-passthrough scale (spacing, radius, …). */
111
+ function varEntries(group: IRGroup, cssPrefix: string[]): Array<readonly [string, string]> {
112
+ return [...flatTokens(group)].map(([key]) => [key, `var(--apollion-${[...cssPrefix, key].join('-')})`] as const);
113
+ }
114
+
115
+ function pushScale(out: string[], scale: string, entries: ReadonlyArray<readonly [string, string]>): void {
116
+ if (entries.length === 0) return;
117
+ out.push(` ${scale}: {`);
118
+ for (const [key, value] of entries) out.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)},`);
119
+ out.push(' },');
120
+ }
121
+
122
+ /**
123
+ * Render the generated Tailwind preset module (`tailwind/preset.mjs`) —
124
+ * runtime-importable ESM, `theme.extend` only (never replaces Tailwind scales).
125
+ */
126
+ export function renderTailwindPreset(ir: IRDocument): string {
127
+ const out: string[] = [
128
+ HEADER_LINE,
129
+ '// Tailwind preset over the Apollion foundation tokens — requires dist/css/ vars in scope.',
130
+ '',
131
+ 'const preset = {',
132
+ ' theme: {',
133
+ ' extend: {',
134
+ ];
135
+
136
+ const colors = colorEntries(ir);
137
+ out.push(' colors: {');
138
+ for (const { key, cssVar, opaque } of colors) {
139
+ if (opaque) {
140
+ out.push(` ${JSON.stringify(key)}: ${JSON.stringify(`rgb(var(${cssVar}-rgb) / <alpha-value>)`)},`);
141
+ } else {
142
+ out.push(` // ${key} carries its own alpha — excluded from the <alpha-value> modifier form.`);
143
+ out.push(` ${JSON.stringify(key)}: ${JSON.stringify(`var(${cssVar})`)},`);
144
+ }
145
+ }
146
+ out.push(' },');
147
+
148
+ pushScale(out, 'spacing', varEntries(groupAt(ir.groups, 'spacing'), ['spacing']));
149
+ pushScale(out, 'borderRadius', varEntries(groupAt(ir.groups, 'border', 'radius'), ['border', 'radius']));
150
+ pushScale(out, 'borderWidth', varEntries(groupAt(ir.groups, 'border', 'width'), ['border', 'width']));
151
+ pushScale(out, 'fontFamily', varEntries(groupAt(ir.groups, 'font', 'family'), ['font', 'family']));
152
+ pushScale(out, 'fontSize', varEntries(groupAt(ir.groups, 'font', 'size'), ['font', 'size']));
153
+ pushScale(out, 'fontWeight', varEntries(groupAt(ir.groups, 'font', 'weight'), ['font', 'weight']));
154
+ pushScale(out, 'lineHeight', varEntries(groupAt(ir.groups, 'font', 'lineHeight'), ['font', 'line-height']));
155
+ pushScale(out, 'boxShadow', varEntries(groupAt(ir.groups, 'shadow'), ['shadow']));
156
+
157
+ out.push(' },', ' },', '};', '', 'export default preset;', '');
158
+ return out.join('\n');
159
+ }
160
+
161
+ /**
162
+ * Render the tailwind-merge extension module (`tailwind/tw-merge.mjs`) —
163
+ * the config object for `extendTailwindMerge`, derived from the same IR as the
164
+ * preset so conflict resolution knows every generated key (one step better than
165
+ * the surveyed hand-maintained original).
166
+ */
167
+ export function renderTwMergeExtension(ir: IRDocument): string {
168
+ const keys = (group: IRGroup): string[] => [...flatTokens(group).keys()];
169
+ const list = (values: string[]): string => `[${values.map((v) => JSON.stringify(v)).join(', ')}]`;
170
+
171
+ const out: string[] = [
172
+ HEADER_LINE,
173
+ '// tailwind-merge extension for the generated preset — pass to extendTailwindMerge().',
174
+ '',
175
+ 'export const twMergeExtension = {',
176
+ ' extend: {',
177
+ ' theme: {',
178
+ ` colors: ${list(colorEntries(ir).map((entry) => entry.key))},`,
179
+ ` spacing: ${list(keys(groupAt(ir.groups, 'spacing')))},`,
180
+ ` borderRadius: ${list(keys(groupAt(ir.groups, 'border', 'radius')))},`,
181
+ ` borderWidth: ${list(keys(groupAt(ir.groups, 'border', 'width')))},`,
182
+ ' },',
183
+ ' classGroups: {',
184
+ ` 'font-family': [{ font: ${list(keys(groupAt(ir.groups, 'font', 'family')))} }],`,
185
+ ` 'font-size': [{ text: ${list(keys(groupAt(ir.groups, 'font', 'size')))} }],`,
186
+ ` 'font-weight': [{ font: ${list(keys(groupAt(ir.groups, 'font', 'weight')))} }],`,
187
+ ` leading: [{ leading: ${list(keys(groupAt(ir.groups, 'font', 'lineHeight')))} }],`,
188
+ ` shadow: [{ shadow: ${list(keys(groupAt(ir.groups, 'shadow')))} }],`,
189
+ ' },',
190
+ ' },',
191
+ '};',
192
+ '',
193
+ ];
194
+ return out.join('\n');
195
+ }
@@ -6,20 +6,34 @@
6
6
  * `tokens.bg.primary: '#003750'` not `string`) — autocomplete + compile-time
7
7
  * invariant checks (tech-radar R4). References resolve to literal values, the
8
8
  * primitives layer is omitted, and nested groups (border/font/shadow) nest as
9
- * nested objects.
9
+ * nested objects. Documented tokens (`token-meta.ts`) carry their
10
+ * `$description`/`$deprecated` as JSDoc, so a deprecated token strikes through
11
+ * at the call site before any codemod runs.
10
12
  *
11
13
  * @see ../ir.ts (source of truth) · tech-radar R4 · ADR-006 §3.10
12
14
  */
13
15
 
14
- import type { IRDocument, IRGroup } from '../ir';
16
+ import type { IRDocument, IRGroup, IRToken } from '../ir';
15
17
  import { isToken, rawValue } from '../ir';
16
18
 
17
19
  const HEADER_LINE = '// Generated by apollion-tokens build — DO NOT EDIT. See apollion.config.mjs.';
18
20
 
21
+ function emitDoc(node: IRToken, pad: string, out: string[]): void {
22
+ const lines: string[] = [];
23
+ if (node.description !== undefined) lines.push(node.description);
24
+ if (node.deprecated === true) lines.push('@deprecated');
25
+ else if (typeof node.deprecated === 'string') lines.push(`@deprecated ${node.deprecated}`);
26
+ if (lines.length === 0) return;
27
+ out.push(`${pad}/**`);
28
+ for (const line of lines) out.push(`${pad} * ${line.replace(/\*\//g, '*\\/')}`);
29
+ out.push(`${pad} */`);
30
+ }
31
+
19
32
  function emitGroup(group: IRGroup, depth: number, out: string[]): void {
20
33
  const pad = ' '.repeat(depth);
21
34
  for (const [key, node] of Object.entries(group)) {
22
35
  if (isToken(node)) {
36
+ emitDoc(node, pad, out);
23
37
  out.push(`${pad}${JSON.stringify(key)}: ${JSON.stringify(rawValue(node.value))},`);
24
38
  } else {
25
39
  out.push(`${pad}${key}: {`);
@@ -10,14 +10,16 @@
10
10
  * 4. Core `createFoundation` → FoundationLayer.
11
11
  * 5. If surface === 'negative': core `invertForSurface` applied.
12
12
  *
13
- * **Mode handling (S5 limitation):** `mode` is currently a no-op placeholder
14
- * — palette generation does not differ per mode. ADR-006 E6 (in S6) wires
15
- * `themeMode` resolution into surface inversion. For S5, mode appears only
16
- * in the variant filename so the output topology is ready when S6 plugs in
17
- * real dark-mode logic.
13
+ * **Mode handling (ADR-019):** `mode === 'dark'` derives the palette through
14
+ * the canonical-dark seed swap (`toCanonicalDarkInput`) — the same convention
15
+ * the core invariant suite exercises as "canonical dark". Emitted dark equals
16
+ * the runtime canonical dark byte-exactly (parity by composition over the
17
+ * Strangler Fig builders; see `__tests__/parity.test.ts` canonical-dark rows).
18
+ * This retires the S5 placeholder (dark == light) and closes the ADR-006 E6/S6
19
+ * parking.
18
20
  *
19
- * @see ADR-006 §3.6 (Surface-within-mode E6 — pending in S6)
20
- * @see PRD-002 §S5
21
+ * @see ADR-019 (canonical-dark emission) · ADR-006 §3.6 (superseded parking)
22
+ * @see PRD-002 §S5 · docs/spikes/2026-08-13-dark-mode-real-parity.md
21
23
  */
22
24
 
23
25
  import type { FoundationLayer } from '@apollion-dsi/core/themes/foundation';
@@ -40,6 +42,24 @@ export interface VariantThemes {
40
42
  colors: ColorsThemeInterface;
41
43
  }
42
44
 
45
+ /**
46
+ * Canonical dark input — the ADR-010 convention the core invariant suite calls
47
+ * "canonical dark": swap ONLY the contrast/deep axes (`baseLight↔baseDark`,
48
+ * `deepLight↔deepDark`); brand seeds stay fixed. The engine is mode-aware by
49
+ * construction (ADR-012 state floors, ADR-018 `onLight`, `isDarkSurface`), so
50
+ * every derived stop resolves correctly from the swapped seeds. See ADR-019 and
51
+ * `docs/spikes/2026-08-13-dark-mode-real-parity.md`.
52
+ */
53
+ function toCanonicalDarkInput(input: Variant['colors']): Variant['colors'] {
54
+ return {
55
+ ...input,
56
+ baseDark: input.baseLight,
57
+ baseLight: input.baseDark,
58
+ deepDark: input.deepLight,
59
+ deepLight: input.deepDark,
60
+ };
61
+ }
62
+
43
63
  function composeColors(input: Variant['colors']) {
44
64
  return {
45
65
  baseDark: input.baseDark,
@@ -68,7 +88,11 @@ function composeColors(input: Variant['colors']) {
68
88
  * deterministic.
69
89
  */
70
90
  export function buildVariantThemes(variant: Variant, spacingInput: SpacingInput = defaultInputSpacing): VariantThemes {
71
- const themeColors = composeColors(variant.colors) as unknown as ColorsThemeInterface;
91
+ // ADR-019: dark stops being a placeholder — the palette derives from the
92
+ // canonical-dark seed swap. Emitted dark == runtime canonical dark, byte-exact
93
+ // (parity by composition over the Strangler Fig builders).
94
+ const colorsInput = variant.mode === 'dark' ? toCanonicalDarkInput(variant.colors) : variant.colors;
95
+ const themeColors = composeColors(colorsInput) as unknown as ColorsThemeInterface;
72
96
  const themeSpacing = createSpacing(spacingInput, variant.dimension);
73
97
  const semantic = createSemantic({ colors: themeColors as never, spacing: themeSpacing as never });
74
98