@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.
- package/README.md +47 -26
- package/lib/build.cjs +11 -6
- package/lib/build.esm.js +11 -6
- package/lib/builders/colors.d.ts +3 -3
- package/lib/cli.mjs +13 -8
- package/lib/config-loader.cjs +2 -2
- package/lib/config-loader.esm.js +1 -1
- package/lib/config-schema.cjs +1 -1
- package/lib/config-schema.esm.js +1 -1
- package/package.json +3 -2
- package/src/build.ts +50 -4
- package/src/builders/colors.ts +13 -13
- package/src/cli.ts +1 -1
- package/src/config-schema-runtime.ts +2 -0
- package/src/config-schema.ts +10 -1
- package/src/ir.ts +2 -2
- package/src/manifest.ts +1 -1
- package/src/renderers/css.ts +36 -1
- package/src/renderers/design-md.ts +107 -0
- package/src/renderers/resolver.ts +4 -3
- package/src/renderers/tailwind.ts +195 -0
- package/src/renderers/ts.ts +16 -2
- package/src/theme-factory.ts +32 -8
|
@@ -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
|
-
* **
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
+
}
|
package/src/renderers/ts.ts
CHANGED
|
@@ -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}: {`);
|
package/src/theme-factory.ts
CHANGED
|
@@ -10,14 +10,16 @@
|
|
|
10
10
|
* 4. Core `createFoundation` → FoundationLayer.
|
|
11
11
|
* 5. If surface === 'negative': core `invertForSurface` applied.
|
|
12
12
|
*
|
|
13
|
-
* **Mode handling (
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
|