@cdx-ui/styles 0.0.1-beta.54 → 0.0.1-beta.56

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.
Files changed (46) hide show
  1. package/css/theme.css +2 -2
  2. package/lib/commonjs/applyThemeOverride.js +154 -0
  3. package/lib/commonjs/applyThemeOverride.js.map +1 -0
  4. package/lib/commonjs/index.js +69 -73
  5. package/lib/commonjs/index.js.map +1 -1
  6. package/lib/commonjs/palette.js +180 -0
  7. package/lib/commonjs/palette.js.map +1 -0
  8. package/lib/commonjs/theming.js +218 -0
  9. package/lib/commonjs/theming.js.map +1 -0
  10. package/lib/commonjs/types.js +75 -0
  11. package/lib/commonjs/types.js.map +1 -0
  12. package/lib/module/applyThemeOverride.js +149 -0
  13. package/lib/module/applyThemeOverride.js.map +1 -0
  14. package/lib/module/index.js +8 -62
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/module/palette.js +176 -0
  17. package/lib/module/palette.js.map +1 -0
  18. package/lib/module/theming.js +202 -0
  19. package/lib/module/theming.js.map +1 -0
  20. package/lib/module/types.js +71 -0
  21. package/lib/module/types.js.map +1 -0
  22. package/lib/runtime/prestige-vs-default.json +1 -0
  23. package/lib/runtime/pulse-vs-default.json +1 -0
  24. package/lib/runtime/token-to-css-var.json +632 -0
  25. package/lib/typescript/applyThemeOverride.d.ts +26 -0
  26. package/lib/typescript/applyThemeOverride.d.ts.map +1 -0
  27. package/lib/typescript/index.d.ts +7 -89
  28. package/lib/typescript/index.d.ts.map +1 -1
  29. package/lib/typescript/palette.d.ts +48 -0
  30. package/lib/typescript/palette.d.ts.map +1 -0
  31. package/lib/typescript/theming.d.ts +40 -0
  32. package/lib/typescript/theming.d.ts.map +1 -0
  33. package/lib/typescript/types.d.ts +90 -0
  34. package/lib/typescript/types.d.ts.map +1 -0
  35. package/package.json +27 -8
  36. package/runtime/prestige-vs-default.json +1 -0
  37. package/runtime/pulse-vs-default.json +1 -0
  38. package/runtime/token-to-css-var.json +632 -0
  39. package/src/__tests__/applyThemeOverride.test.ts +488 -0
  40. package/src/__tests__/generateColorScale.test.ts +202 -0
  41. package/src/__tests__/theming.test.ts +525 -0
  42. package/src/applyThemeOverride.ts +139 -0
  43. package/src/index.ts +33 -103
  44. package/src/palette.ts +226 -0
  45. package/src/theming.ts +230 -0
  46. package/src/types.ts +112 -0
package/src/index.ts CHANGED
@@ -1,115 +1,45 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Types
2
+ // Types & Constants
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
- /** A DTCG token leaf node. */
6
- export interface TokenValue {
7
- $type: string;
8
- $value: string | number;
9
- $extensions?: Record<string, unknown>;
10
- }
11
-
12
- /** Recursive token group — every non-leaf node in a theme object. */
13
- export interface TokenGroup {
14
- [key: string]: TokenValue | TokenGroup;
15
- }
16
-
17
- /** The three built-in Forge UI theme presets. */
18
- export type Preset = 'poise' | 'prestige' | 'pulse';
19
-
20
- /** Theme metadata stored under `$extensions.com.forge.ui.theme`. */
21
- export interface ThemeMetadata {
22
- name: string;
23
- preset: Preset;
24
- schemaVersion: string;
25
- }
26
-
27
- /** Override metadata stored under `$extensions.com.forge.ui.themeOverride`. */
28
- export interface ThemeOverrideMetadata {
29
- basePreset: Preset;
30
- schemaVersion: string;
31
- }
32
-
33
- /**
34
- * A complete Forge UI theme object (DTCG-compatible).
35
- *
36
- * Presets (Poise, Prestige, Pulse) are full theme objects. At runtime the
37
- * build-time default preset is augmented by FI overrides via
38
- * `applyThemeOverrides`.
39
- */
40
- export type ThemeObject = TokenGroup & {
41
- $extensions: {
42
- 'com.forge.ui.theme': ThemeMetadata;
43
- [key: string]: unknown;
44
- };
45
- modes: {
46
- light: TokenGroup;
47
- dark: TokenGroup;
48
- };
49
- platform: {
50
- web: TokenGroup;
51
- ios: TokenGroup;
52
- android: TokenGroup;
53
- };
54
- };
55
-
56
- /**
57
- * A theme override using the hybrid input + semantic structure.
58
- *
59
- * - `inputs` — flat map of abstract FI-selected brand values (e.g.
60
- * `brandPrimary`, `displayFont`). Palette generation expands these into
61
- * full token scales at runtime.
62
- * - `overrides` — per-mode flat maps of token dot-paths to resolved values
63
- * for direct semantic token overrides beyond what inputs generate.
64
- * - `$extensions` — required metadata including base preset and schema version.
65
- */
66
- export interface ThemeOverride {
67
- $extensions: {
68
- 'com.forge.ui.themeOverride': ThemeOverrideMetadata;
69
- [key: string]: unknown;
70
- };
71
- inputs?: Record<string, string | number>;
72
- overrides?: Partial<Record<Mode, Record<string, string | number>>>;
73
- }
74
-
75
- export type Mode = 'light' | 'dark';
76
- export type Platform = 'web' | 'ios' | 'android';
5
+ export {
6
+ OVERRIDE_SCHEMA_VERSION,
7
+ SUPPORTED_OVERRIDE_SCHEMA_VERSIONS,
8
+ INPUT_TOKEN_MAP,
9
+ presetFonts,
10
+ } from './types';
11
+ export type {
12
+ TokenValue,
13
+ TokenGroup,
14
+ Preset,
15
+ ThemeMetadata,
16
+ ThemeOverrideMetadata,
17
+ ThemeObject,
18
+ ThemeOverride,
19
+ Mode,
20
+ Platform,
21
+ } from './types';
77
22
 
78
23
  // ---------------------------------------------------------------------------
79
- // Constants
24
+ // Palette generation
80
25
  // ---------------------------------------------------------------------------
81
26
 
82
- /** Current override schema version. Consuming apps gate compatibility on this. */
83
- export const OVERRIDE_SCHEMA_VERSION = '1.0.0' as const;
84
-
85
- /**
86
- * Schema versions that consuming apps accept. Includes the current version and
87
- * may include the immediately prior version during transition windows.
88
- * @see docs/internal/token-architecture/16-override-structure.md § 4
89
- */
90
- export const SUPPORTED_OVERRIDE_SCHEMA_VERSIONS: readonly string[] = ['1.0.0'] as const;
27
+ export { generateColorScale, generatePalettesFromInputs } from './palette';
28
+ export type { PaletteCategory, PaletteScale, PaletteStep, PaletteTokenMap } from './palette';
91
29
 
92
- /**
93
- * Schema-level mapping from known input keys to the token path pattern each
94
- * affects. Used by override application (S5) to route inputs to the correct
95
- * palette/token namespace. Distinct from the full runtime map (S3).
96
- */
97
- export const INPUT_TOKEN_MAP = {
98
- brandPrimary: 'color.brand',
99
- accentPrimary: 'color.accent',
100
- basePrimary: 'color.base',
101
- displayFont: 'font.display',
102
- } as const satisfies Record<string, string>;
30
+ // ---------------------------------------------------------------------------
31
+ // Theming utilities
32
+ // ---------------------------------------------------------------------------
103
33
 
104
- /**
105
- * Allowed display font families per preset, consumed by the theme editor for
106
- * font selection and by consuming apps for validation.
107
- */
108
- export const presetFonts: Record<Preset, readonly string[]> = {
109
- poise: ['Crimson Pro', 'Bitter', 'DM Sans'],
110
- prestige: ['Libre Caslon Text', 'Cormorant', 'Libre Franklin'],
111
- pulse: ['Outfit', 'Manrope', 'Public Sans'],
112
- } as const;
34
+ export {
35
+ isSchemaVersionSupported,
36
+ presetPatchToUniwindMaps,
37
+ themeOverrideToUniwindMaps,
38
+ applyThemeOverride,
39
+ DEFAULT_PRESET,
40
+ } from './theming';
41
+ export type { CssVariableMaps, RuntimeMap } from './theming';
42
+ export type { ApplyThemeOverrideOptions, ApplyThemeOverrideResult } from './applyThemeOverride';
113
43
 
114
44
  // ---------------------------------------------------------------------------
115
45
  // Hooks
package/src/palette.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { BackgroundColor, Color, Theme, type CssColor } from '@adobe/leonardo-contrast-colors';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /** Palette step keys produced by `generateColorScale`. */
8
+ export type PaletteStep =
9
+ | '50'
10
+ | '100'
11
+ | '200'
12
+ | '300'
13
+ | '400'
14
+ | '500'
15
+ | '600'
16
+ | '700'
17
+ | '800'
18
+ | '900'
19
+ | '950'
20
+ | 'input';
21
+
22
+ /** Token category that supports palette generation. */
23
+ export type PaletteCategory = 'brand' | 'accent';
24
+
25
+ /** Map of palette step → resolved hex color. */
26
+ export type PaletteScale = Record<PaletteStep, string>;
27
+
28
+ /** Map of token dot-path → resolved hex color (e.g. `color.brand.500` → `#548cdc`). */
29
+ export type PaletteTokenMap = Record<string, string>;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Constants
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const REFERENCE_BACKGROUND = '#ffffff';
36
+
37
+ const PALETTE_STEPS = [
38
+ '50',
39
+ '100',
40
+ '200',
41
+ '300',
42
+ '400',
43
+ '500',
44
+ '600',
45
+ '700',
46
+ '800',
47
+ '900',
48
+ '950',
49
+ ] as const;
50
+
51
+ /**
52
+ * Contrast ratios for each palette step against white (#ffffff).
53
+ *
54
+ * Step 700 targets WCAG AA for normal text (≥ 4.5:1).
55
+ */
56
+ const CONTRAST_RATIOS: Record<(typeof PALETTE_STEPS)[number], number> = {
57
+ '50': 1.04,
58
+ '100': 1.16,
59
+ '200': 1.46,
60
+ '300': 1.85,
61
+ '400': 2.45,
62
+ '500': 3.4,
63
+ '600': 4.8,
64
+ '700': 6.4,
65
+ '800': 8.6,
66
+ '900': 12,
67
+ '950': 16.8,
68
+ };
69
+
70
+ const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Validation
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Validate and normalise a hex color string.
78
+ *
79
+ * Accepts `#RGB` (4 chars) or `#RRGGBB` (7 chars). Throws a descriptive
80
+ * `TypeError` for any other input.
81
+ *
82
+ * @returns The normalised 7-character hex string (e.g. `#aabbcc`).
83
+ */
84
+ function validateHex(hex: string): CssColor {
85
+ if (typeof hex !== 'string') {
86
+ throw new TypeError(`Invalid hex color: expected a string, received ${typeof hex}`);
87
+ }
88
+
89
+ if (!HEX_REGEX.test(hex)) {
90
+ throw new TypeError(
91
+ `Invalid hex color "${hex}": must be a 4-character (#RGB) or 7-character (#RRGGBB) hex string starting with "#"`,
92
+ );
93
+ }
94
+
95
+ if (hex.length === 4) {
96
+ const [, r, g, b] = hex;
97
+ return `#${r}${r}${g}${g}${b}${b}`.toLowerCase() as CssColor;
98
+ }
99
+
100
+ return hex.toLowerCase() as CssColor;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Core generation — wraps Leonardo behind a thin interface
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Generate an 11-step contrast-based color scale from a single hex color.
109
+ *
110
+ * Uses `@adobe/leonardo-contrast-colors` internally. The generation algorithm
111
+ * is wrapped behind this function so the underlying library is swappable
112
+ * without changing the public API.
113
+ *
114
+ * @param hex - A valid hex color string (`#RGB` or `#RRGGBB`).
115
+ * @param category - The token namespace (`'brand'` or `'accent'`).
116
+ * @returns A `PaletteTokenMap` keyed by token dot-paths
117
+ * (e.g. `"color.brand.50"` through `"color.brand.950"` plus `"color.brand.input"`).
118
+ */
119
+ export function generateColorScale(hex: string, category: PaletteCategory): PaletteTokenMap {
120
+ const normalised = validateHex(hex);
121
+
122
+ const ratiosObject: Record<string, number> = {};
123
+ for (const step of PALETTE_STEPS) {
124
+ ratiosObject[step] = CONTRAST_RATIOS[step];
125
+ }
126
+
127
+ const color = new Color({
128
+ name: 'palette',
129
+ colorKeys: [normalised],
130
+ colorSpace: 'CAM02p',
131
+ ratios: ratiosObject,
132
+ smooth: true,
133
+ output: 'HEX',
134
+ });
135
+
136
+ const bg = new BackgroundColor({
137
+ name: 'background',
138
+ colorKeys: [REFERENCE_BACKGROUND],
139
+ colorSpace: 'CAM02p',
140
+ smooth: true,
141
+ ratios: [],
142
+ output: 'HEX',
143
+ });
144
+
145
+ const theme = new Theme({
146
+ colors: [color],
147
+ backgroundColor: bg,
148
+ lightness: 100,
149
+ contrast: 1,
150
+ saturation: 100,
151
+ output: 'HEX',
152
+ formula: 'wcag2',
153
+ });
154
+
155
+ const pairs = theme.contrastColorPairs;
156
+
157
+ const tokenPrefix = `color.${category}`;
158
+ const result: PaletteTokenMap = {};
159
+
160
+ for (const step of PALETTE_STEPS) {
161
+ result[`${tokenPrefix}.${step}`] = pairs[step].toLowerCase();
162
+ }
163
+
164
+ result[`${tokenPrefix}.input`] = normalised;
165
+
166
+ return result;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Convenience wrapper
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Colour-input keys whose token path starts with `color.` — these trigger
175
+ * palette generation. Mirrors the colour entries from `INPUT_TOKEN_MAP`
176
+ * (defined in the package barrel) without creating a circular import.
177
+ *
178
+ * Keep in sync with `INPUT_TOKEN_MAP` in `./index.ts`.
179
+ */
180
+ const COLOR_INPUT_ENTRIES: readonly (readonly [string, string])[] = [
181
+ ['brandPrimary', 'color.brand'],
182
+ ['accentPrimary', 'color.accent'],
183
+ ] as const;
184
+
185
+ /**
186
+ * Generate palettes for all colour inputs in a theme override `inputs` object.
187
+ *
188
+ * Iterates over known colour-input keys that map to a `color.*` token
189
+ * namespace. For each matching key present in `inputs`, runs
190
+ * `generateColorScale` and merges the results into a single flat map of
191
+ * token dot-paths to hex values — ready for the S5 override application
192
+ * merge step.
193
+ *
194
+ * Non-colour inputs (e.g. `displayFont`) are silently skipped.
195
+ *
196
+ * @param inputs - The `inputs` object from a `ThemeOverride`.
197
+ * @returns A merged `Record<string, string>` of all generated token
198
+ * dot-paths to hex values.
199
+ *
200
+ * @example
201
+ * ```ts
202
+ * const inputs = { brandPrimary: '#0052cc', accentPrimary: '#FF8C42', displayFont: 'Poppins' };
203
+ * const result = generatePalettesFromInputs(inputs);
204
+ * // {
205
+ * // "color.brand.50": "#...", …, "color.brand.input": "#0052cc",
206
+ * // "color.accent.50": "#...", …, "color.accent.input": "#ff8c42"
207
+ * // }
208
+ * ```
209
+ */
210
+ export function generatePalettesFromInputs(
211
+ inputs: Record<string, string | number>,
212
+ ): PaletteTokenMap {
213
+ const result: PaletteTokenMap = {};
214
+
215
+ for (const [inputKey, tokenPath] of COLOR_INPUT_ENTRIES) {
216
+ const value = inputs[inputKey];
217
+ if (typeof value !== 'string') continue;
218
+
219
+ const category = tokenPath.replace('color.', '') as PaletteCategory;
220
+ const scale = generateColorScale(value, category);
221
+
222
+ Object.assign(result, scale);
223
+ }
224
+
225
+ return result;
226
+ }
package/src/theming.ts ADDED
@@ -0,0 +1,230 @@
1
+ import { generatePalettesFromInputs } from './palette';
2
+ import {
3
+ INPUT_TOKEN_MAP,
4
+ SUPPORTED_OVERRIDE_SCHEMA_VERSIONS,
5
+ type Mode,
6
+ type Platform,
7
+ type ThemeOverride,
8
+ type TokenGroup,
9
+ type TokenValue,
10
+ } from './types';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Re-export orchestrator
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export { applyThemeOverride, DEFAULT_PRESET } from './applyThemeOverride';
17
+ export type { ApplyThemeOverrideOptions, ApplyThemeOverrideResult } from './applyThemeOverride';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Per-mode CSS variable maps ready for `Uniwind.updateCSSVariables`. */
24
+ export interface CssVariableMaps {
25
+ light: Record<string, string>;
26
+ dark: Record<string, string>;
27
+ }
28
+
29
+ /** Runtime map: token dot-paths → CSS custom property names. */
30
+ export type RuntimeMap = Record<string, string>;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Schema version gate
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Check whether a `ThemeOverride`'s schema version is supported by the current
38
+ * build of `@cdx-ui/styles`.
39
+ */
40
+ export function isSchemaVersionSupported(override: ThemeOverride): boolean {
41
+ const version = override.$extensions['com.forge.ui.themeOverride'].schemaVersion;
42
+ return SUPPORTED_OVERRIDE_SCHEMA_VERSIONS.includes(version);
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // DTCG tree walker
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function isTokenValue(node: unknown): node is TokenValue {
50
+ return typeof node === 'object' && node !== null && '$value' in node && '$type' in node;
51
+ }
52
+
53
+ /**
54
+ * Recursively walk a nested DTCG object and collect all `$value` leaves with
55
+ * their full dot-path.
56
+ */
57
+ function flattenDtcg(
58
+ node: TokenGroup,
59
+ prefix: string,
60
+ result: Record<string, string | number>,
61
+ ): void {
62
+ for (const key of Object.keys(node)) {
63
+ if (key.startsWith('$')) continue;
64
+
65
+ const child = node[key];
66
+ const path = prefix ? `${prefix}.${key}` : key;
67
+
68
+ if (isTokenValue(child)) {
69
+ result[path] = child.$value;
70
+ } else if (typeof child === 'object' && child !== null) {
71
+ flattenDtcg(child, path, result);
72
+ }
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Shared mode-bucketing
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function bucketByMode(
81
+ flatTokens: Record<string, string | number>,
82
+ runtimeMap: RuntimeMap,
83
+ runtimePlatform: Platform,
84
+ ): CssVariableMaps {
85
+ const light: Record<string, string> = {};
86
+ const dark: Record<string, string> = {};
87
+
88
+ for (const [dotPath, value] of Object.entries(flatTokens)) {
89
+ const strValue = String(value);
90
+
91
+ if (dotPath.startsWith('modes.light.')) {
92
+ const cssVar = runtimeMap[dotPath];
93
+ if (cssVar) light[cssVar] = strValue;
94
+ } else if (dotPath.startsWith('modes.dark.')) {
95
+ const cssVar = runtimeMap[dotPath];
96
+ if (cssVar) dark[cssVar] = strValue;
97
+ } else if (dotPath.startsWith('platform.')) {
98
+ const segments = dotPath.split('.');
99
+ const platform = segments[1];
100
+ if (platform !== runtimePlatform) continue;
101
+
102
+ const cssVar = runtimeMap[dotPath];
103
+ if (cssVar) {
104
+ light[cssVar] = strValue;
105
+ dark[cssVar] = strValue;
106
+ }
107
+ } else {
108
+ const cssVar = runtimeMap[dotPath];
109
+ if (cssVar) {
110
+ light[cssVar] = strValue;
111
+ dark[cssVar] = strValue;
112
+ }
113
+ }
114
+ }
115
+
116
+ return { light, dark };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Preset patch → Uniwind maps
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Convert a nested DTCG preset patch (sparse, `$type`/`$value` leaves) into
125
+ * per-mode CSS variable maps suitable for `Uniwind.updateCSSVariables`.
126
+ *
127
+ * @param patch - A sparse DTCG token object (preset patch).
128
+ * @param runtimeMap - The generated token-to-CSS-var mapping.
129
+ * @param runtimePlatform - Current platform (`'web' | 'ios' | 'android'`).
130
+ */
131
+ export function presetPatchToUniwindMaps(
132
+ patch: TokenGroup,
133
+ runtimeMap: RuntimeMap,
134
+ runtimePlatform: Platform,
135
+ ): CssVariableMaps {
136
+ const flat: Record<string, string | number> = {};
137
+ flattenDtcg(patch, '', flat);
138
+ return bucketByMode(flat, runtimeMap, runtimePlatform);
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // FI override → Uniwind maps
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Non-color input keys from INPUT_TOKEN_MAP whose token path does NOT start
147
+ * with `color.`. These are handled separately from palette generation.
148
+ */
149
+ const FONT_INPUT_ENTRIES: readonly (readonly [string, string])[] = (
150
+ Object.entries(INPUT_TOKEN_MAP) as [string, string][]
151
+ ).filter(([, tokenPath]) => !tokenPath.startsWith('color.'));
152
+
153
+ /**
154
+ * Convert a hybrid FI override (`ThemeOverride`) into per-mode CSS variable
155
+ * maps suitable for `Uniwind.updateCSSVariables`.
156
+ *
157
+ * Processing:
158
+ * 1. Generate palettes from color `inputs` (via S4).
159
+ * 2. Map font inputs to token paths.
160
+ * 3. For each mode, merge palette + fonts + explicit `overrides.{mode}` entries.
161
+ * 4. Map all token dot-paths to CSS variable names via runtime map.
162
+ *
163
+ * @param override - A `ThemeOverride` object.
164
+ * @param runtimeMap - The generated token-to-CSS-var mapping.
165
+ * @param runtimePlatform - Current platform (`'web' | 'ios' | 'android'`).
166
+ */
167
+ export function themeOverrideToUniwindMaps(
168
+ override: ThemeOverride,
169
+ runtimeMap: RuntimeMap,
170
+ // runtimePlatform: Platform,
171
+ ): CssVariableMaps {
172
+ const light: Record<string, string> = {};
173
+ const dark: Record<string, string> = {};
174
+
175
+ const inputs = override.inputs;
176
+ const overrides = override.overrides;
177
+
178
+ if (!inputs && !overrides) {
179
+ return { light, dark };
180
+ }
181
+
182
+ // --- Palette generation from colour inputs ---
183
+ let paletteTokens: Record<string, string> = {};
184
+ if (inputs) {
185
+ paletteTokens = generatePalettesFromInputs(inputs);
186
+ }
187
+
188
+ // --- Font input mapping ---
189
+ const fontTokens: Record<string, string> = {};
190
+ if (inputs) {
191
+ for (const [inputKey, tokenPath] of FONT_INPUT_ENTRIES) {
192
+ const value = inputs[inputKey];
193
+ if (value !== undefined) {
194
+ fontTokens[tokenPath] = String(value);
195
+ }
196
+ }
197
+ }
198
+
199
+ // --- Merge per mode ---
200
+ const modes: Mode[] = ['light', 'dark'];
201
+
202
+ for (const mode of modes) {
203
+ const bucket = mode === 'light' ? light : dark;
204
+
205
+ // 1. Palette-generated primitives (mode-independent → both buckets)
206
+ for (const [dotPath, hexValue] of Object.entries(paletteTokens)) {
207
+ const cssVar = runtimeMap[dotPath];
208
+ if (cssVar) bucket[cssVar] = hexValue;
209
+ }
210
+
211
+ // 2. Font input mappings (mode-independent → both buckets)
212
+ for (const [tokenPath, value] of Object.entries(fontTokens)) {
213
+ const modePrefixed = `modes.${mode}.${tokenPath}`;
214
+ const cssVar = runtimeMap[modePrefixed] ?? runtimeMap[tokenPath];
215
+ if (cssVar) bucket[cssVar] = value;
216
+ }
217
+
218
+ // 3. Explicit per-mode overrides (wins on collision)
219
+ const modeOverrides = overrides?.[mode];
220
+ if (modeOverrides) {
221
+ for (const [dotPath, value] of Object.entries(modeOverrides)) {
222
+ const modePrefixed = `modes.${mode}.${dotPath}`;
223
+ const cssVar = runtimeMap[modePrefixed] ?? runtimeMap[dotPath];
224
+ if (cssVar) bucket[cssVar] = String(value);
225
+ }
226
+ }
227
+ }
228
+
229
+ return { light, dark };
230
+ }