@apollion-dsi/tokens 5.2.0 → 5.4.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,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