@apollion-dsi/tokens 5.3.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.
- package/README.md +5 -1
- 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,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
|
|