@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.
- package/css/theme.css +2 -2
- package/lib/commonjs/applyThemeOverride.js +154 -0
- package/lib/commonjs/applyThemeOverride.js.map +1 -0
- package/lib/commonjs/index.js +69 -73
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/palette.js +180 -0
- package/lib/commonjs/palette.js.map +1 -0
- package/lib/commonjs/theming.js +218 -0
- package/lib/commonjs/theming.js.map +1 -0
- package/lib/commonjs/types.js +75 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/applyThemeOverride.js +149 -0
- package/lib/module/applyThemeOverride.js.map +1 -0
- package/lib/module/index.js +8 -62
- package/lib/module/index.js.map +1 -1
- package/lib/module/palette.js +176 -0
- package/lib/module/palette.js.map +1 -0
- package/lib/module/theming.js +202 -0
- package/lib/module/theming.js.map +1 -0
- package/lib/module/types.js +71 -0
- package/lib/module/types.js.map +1 -0
- package/lib/runtime/prestige-vs-default.json +1 -0
- package/lib/runtime/pulse-vs-default.json +1 -0
- package/lib/runtime/token-to-css-var.json +632 -0
- package/lib/typescript/applyThemeOverride.d.ts +26 -0
- package/lib/typescript/applyThemeOverride.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +7 -89
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/palette.d.ts +48 -0
- package/lib/typescript/palette.d.ts.map +1 -0
- package/lib/typescript/theming.d.ts +40 -0
- package/lib/typescript/theming.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +90 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/package.json +27 -8
- package/runtime/prestige-vs-default.json +1 -0
- package/runtime/pulse-vs-default.json +1 -0
- package/runtime/token-to-css-var.json +632 -0
- package/src/__tests__/applyThemeOverride.test.ts +488 -0
- package/src/__tests__/generateColorScale.test.ts +202 -0
- package/src/__tests__/theming.test.ts +525 -0
- package/src/applyThemeOverride.ts +139 -0
- package/src/index.ts +33 -103
- package/src/palette.ts +226 -0
- package/src/theming.ts +230 -0
- package/src/types.ts +112 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import {
|
|
2
|
+
presetPatchToUniwindMaps,
|
|
3
|
+
themeOverrideToUniwindMaps,
|
|
4
|
+
isSchemaVersionSupported,
|
|
5
|
+
type RuntimeMap,
|
|
6
|
+
} from '../theming';
|
|
7
|
+
import type { ThemeOverride, TokenGroup } from '../types';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Mock runtime map — representative subset of the generated map
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const MOCK_RUNTIME_MAP: RuntimeMap = {
|
|
14
|
+
// Primitives (mode-independent)
|
|
15
|
+
'color.brand.50': '--color-brand-50',
|
|
16
|
+
'color.brand.100': '--color-brand-100',
|
|
17
|
+
'color.brand.200': '--color-brand-200',
|
|
18
|
+
'color.brand.300': '--color-brand-300',
|
|
19
|
+
'color.brand.400': '--color-brand-400',
|
|
20
|
+
'color.brand.500': '--color-brand-500',
|
|
21
|
+
'color.brand.600': '--color-brand-600',
|
|
22
|
+
'color.brand.700': '--color-brand-700',
|
|
23
|
+
'color.brand.800': '--color-brand-800',
|
|
24
|
+
'color.brand.900': '--color-brand-900',
|
|
25
|
+
'color.brand.950': '--color-brand-950',
|
|
26
|
+
'color.brand.input': '--color-brand-input',
|
|
27
|
+
'color.accent.50': '--color-accent-50',
|
|
28
|
+
'color.accent.100': '--color-accent-100',
|
|
29
|
+
'color.accent.200': '--color-accent-200',
|
|
30
|
+
'color.accent.300': '--color-accent-300',
|
|
31
|
+
'color.accent.400': '--color-accent-400',
|
|
32
|
+
'color.accent.500': '--color-accent-500',
|
|
33
|
+
'color.accent.600': '--color-accent-600',
|
|
34
|
+
'color.accent.700': '--color-accent-700',
|
|
35
|
+
'color.accent.800': '--color-accent-800',
|
|
36
|
+
'color.accent.900': '--color-accent-900',
|
|
37
|
+
'color.accent.950': '--color-accent-950',
|
|
38
|
+
'color.accent.input': '--color-accent-input',
|
|
39
|
+
'font.display': '--font-display',
|
|
40
|
+
|
|
41
|
+
// Mode-prefixed semantic tokens
|
|
42
|
+
'modes.light.color.surface.background': '--color-surface-background',
|
|
43
|
+
'modes.dark.color.surface.background': '--color-surface-background',
|
|
44
|
+
'modes.light.color.content.primary': '--color-content-primary',
|
|
45
|
+
'modes.dark.color.content.primary': '--color-content-primary',
|
|
46
|
+
'modes.light.color.stroke.primary': '--color-stroke-primary',
|
|
47
|
+
'modes.dark.color.stroke.primary': '--color-stroke-primary',
|
|
48
|
+
'modes.light.font.display': '--font-display',
|
|
49
|
+
'modes.dark.font.display': '--font-display',
|
|
50
|
+
'modes.light.border-radius.button': '--border-radius-button',
|
|
51
|
+
'modes.dark.border-radius.button': '--border-radius-button',
|
|
52
|
+
|
|
53
|
+
// Platform-prefixed tokens
|
|
54
|
+
'platform.web.font.sans': '--font-sans',
|
|
55
|
+
'platform.ios.font.sans': '--font-sans',
|
|
56
|
+
'platform.android.font.sans': '--font-sans',
|
|
57
|
+
'platform.web.font.mono': '--font-mono',
|
|
58
|
+
'platform.ios.font.mono': '--font-mono',
|
|
59
|
+
'platform.android.font.mono': '--font-mono',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// presetPatchToUniwindMaps
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe('presetPatchToUniwindMaps', () => {
|
|
67
|
+
it('converts a mode-scoped DTCG patch into per-mode variable maps', () => {
|
|
68
|
+
const patch: TokenGroup = {
|
|
69
|
+
modes: {
|
|
70
|
+
light: {
|
|
71
|
+
color: {
|
|
72
|
+
surface: {
|
|
73
|
+
background: { $type: 'color', $value: '#ffffff' },
|
|
74
|
+
},
|
|
75
|
+
content: {
|
|
76
|
+
primary: { $type: 'color', $value: '#1a1a1a' },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
dark: {
|
|
81
|
+
color: {
|
|
82
|
+
surface: {
|
|
83
|
+
background: { $type: 'color', $value: '#0d0d0d' },
|
|
84
|
+
},
|
|
85
|
+
content: {
|
|
86
|
+
primary: { $type: 'color', $value: '#f5f5f5' },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
} as unknown as TokenGroup;
|
|
92
|
+
|
|
93
|
+
const result = presetPatchToUniwindMaps(patch, MOCK_RUNTIME_MAP, 'web');
|
|
94
|
+
|
|
95
|
+
expect(result.light).toEqual({
|
|
96
|
+
'--color-surface-background': '#ffffff',
|
|
97
|
+
'--color-content-primary': '#1a1a1a',
|
|
98
|
+
});
|
|
99
|
+
expect(result.dark).toEqual({
|
|
100
|
+
'--color-surface-background': '#0d0d0d',
|
|
101
|
+
'--color-content-primary': '#f5f5f5',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('places mode-independent primitives in BOTH light and dark maps', () => {
|
|
106
|
+
const patch: TokenGroup = {
|
|
107
|
+
color: {
|
|
108
|
+
brand: {
|
|
109
|
+
'500': { $type: 'color', $value: '#0052cc' },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
} as unknown as TokenGroup;
|
|
113
|
+
|
|
114
|
+
const result = presetPatchToUniwindMaps(patch, MOCK_RUNTIME_MAP, 'web');
|
|
115
|
+
|
|
116
|
+
expect(result.light['--color-brand-500']).toBe('#0052cc');
|
|
117
|
+
expect(result.dark['--color-brand-500']).toBe('#0052cc');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('filters platform tokens to only include matching runtimePlatform', () => {
|
|
121
|
+
const patch: TokenGroup = {
|
|
122
|
+
platform: {
|
|
123
|
+
web: {
|
|
124
|
+
font: {
|
|
125
|
+
sans: { $type: 'fontFamily', $value: 'Inter' },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
ios: {
|
|
129
|
+
font: {
|
|
130
|
+
sans: { $type: 'fontFamily', $value: 'SF Pro' },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
android: {
|
|
134
|
+
font: {
|
|
135
|
+
sans: { $type: 'fontFamily', $value: 'Roboto' },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
} as unknown as TokenGroup;
|
|
140
|
+
|
|
141
|
+
const resultWeb = presetPatchToUniwindMaps(patch, MOCK_RUNTIME_MAP, 'web');
|
|
142
|
+
expect(resultWeb.light['--font-sans']).toBe('Inter');
|
|
143
|
+
expect(resultWeb.dark['--font-sans']).toBe('Inter');
|
|
144
|
+
|
|
145
|
+
const resultIos = presetPatchToUniwindMaps(patch, MOCK_RUNTIME_MAP, 'ios');
|
|
146
|
+
expect(resultIos.light['--font-sans']).toBe('SF Pro');
|
|
147
|
+
expect(resultIos.dark['--font-sans']).toBe('SF Pro');
|
|
148
|
+
|
|
149
|
+
const resultAndroid = presetPatchToUniwindMaps(patch, MOCK_RUNTIME_MAP, 'android');
|
|
150
|
+
expect(resultAndroid.light['--font-sans']).toBe('Roboto');
|
|
151
|
+
expect(resultAndroid.dark['--font-sans']).toBe('Roboto');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('skips non-matching platform tokens', () => {
|
|
155
|
+
const patch: TokenGroup = {
|
|
156
|
+
platform: {
|
|
157
|
+
ios: {
|
|
158
|
+
font: {
|
|
159
|
+
mono: { $type: 'fontFamily', $value: 'Menlo' },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
} as unknown as TokenGroup;
|
|
164
|
+
|
|
165
|
+
const result = presetPatchToUniwindMaps(patch, MOCK_RUNTIME_MAP, 'web');
|
|
166
|
+
expect(result.light['--font-mono']).toBeUndefined();
|
|
167
|
+
expect(result.dark['--font-mono']).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('silently skips dot-paths not found in the runtime map', () => {
|
|
171
|
+
const patch: TokenGroup = {
|
|
172
|
+
modes: {
|
|
173
|
+
light: {
|
|
174
|
+
some: {
|
|
175
|
+
unknown: {
|
|
176
|
+
token: { $type: 'color', $value: '#abc123' },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
color: {
|
|
182
|
+
surface: {
|
|
183
|
+
background: { $type: 'color', $value: '#ffffff' },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
} as unknown as TokenGroup;
|
|
187
|
+
|
|
188
|
+
const result = presetPatchToUniwindMaps(patch, MOCK_RUNTIME_MAP, 'web');
|
|
189
|
+
|
|
190
|
+
expect(Object.keys(result.light)).toHaveLength(0);
|
|
191
|
+
expect(Object.keys(result.dark)).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('handles mixed mode + primitive + platform tokens', () => {
|
|
195
|
+
const patch: TokenGroup = {
|
|
196
|
+
color: {
|
|
197
|
+
brand: {
|
|
198
|
+
'500': { $type: 'color', $value: '#336699' },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
modes: {
|
|
202
|
+
light: {
|
|
203
|
+
color: {
|
|
204
|
+
stroke: {
|
|
205
|
+
primary: { $type: 'color', $value: '#e0e0e0' },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
dark: {
|
|
210
|
+
color: {
|
|
211
|
+
stroke: {
|
|
212
|
+
primary: { $type: 'color', $value: '#333333' },
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
platform: {
|
|
218
|
+
ios: {
|
|
219
|
+
font: {
|
|
220
|
+
sans: { $type: 'fontFamily', $value: 'SF Pro Display' },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
} as unknown as TokenGroup;
|
|
225
|
+
|
|
226
|
+
const result = presetPatchToUniwindMaps(patch, MOCK_RUNTIME_MAP, 'ios');
|
|
227
|
+
|
|
228
|
+
expect(result.light).toEqual({
|
|
229
|
+
'--color-brand-500': '#336699',
|
|
230
|
+
'--color-stroke-primary': '#e0e0e0',
|
|
231
|
+
'--font-sans': 'SF Pro Display',
|
|
232
|
+
});
|
|
233
|
+
expect(result.dark).toEqual({
|
|
234
|
+
'--color-brand-500': '#336699',
|
|
235
|
+
'--color-stroke-primary': '#333333',
|
|
236
|
+
'--font-sans': 'SF Pro Display',
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('converts numeric $value to string', () => {
|
|
241
|
+
const patch: TokenGroup = {
|
|
242
|
+
modes: {
|
|
243
|
+
light: {
|
|
244
|
+
'border-radius': {
|
|
245
|
+
button: { $type: 'dimension', $value: 8 },
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
} as unknown as TokenGroup;
|
|
250
|
+
|
|
251
|
+
const result = presetPatchToUniwindMaps(patch, MOCK_RUNTIME_MAP, 'web');
|
|
252
|
+
expect(result.light['--border-radius-button']).toBe('8');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// themeOverrideToUniwindMaps
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
describe('themeOverrideToUniwindMaps', () => {
|
|
261
|
+
const baseOverride: ThemeOverride = {
|
|
262
|
+
$extensions: {
|
|
263
|
+
'com.forge.ui.themeOverride': {
|
|
264
|
+
basePreset: 'poise',
|
|
265
|
+
schemaVersion: '1.0.0',
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
describe('inputs only', () => {
|
|
271
|
+
it('generates palette from brandPrimary and places in both mode maps', () => {
|
|
272
|
+
const override: ThemeOverride = {
|
|
273
|
+
...baseOverride,
|
|
274
|
+
inputs: { brandPrimary: '#0052cc' },
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
278
|
+
|
|
279
|
+
expect(result.light['--color-brand-500']).toBeDefined();
|
|
280
|
+
expect(result.dark['--color-brand-500']).toBeDefined();
|
|
281
|
+
expect(result.light['--color-brand-input']).toBe('#0052cc');
|
|
282
|
+
expect(result.dark['--color-brand-input']).toBe('#0052cc');
|
|
283
|
+
expect(result.light['--color-brand-500']).toBe(result.dark['--color-brand-500']);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('generates palette from accentPrimary', () => {
|
|
287
|
+
const override: ThemeOverride = {
|
|
288
|
+
...baseOverride,
|
|
289
|
+
inputs: { accentPrimary: '#ff8c42' },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
293
|
+
|
|
294
|
+
expect(result.light['--color-accent-input']).toBe('#ff8c42');
|
|
295
|
+
expect(result.dark['--color-accent-input']).toBe('#ff8c42');
|
|
296
|
+
expect(result.light['--color-accent-500']).toBeDefined();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('maps displayFont to font.display token path in both modes', () => {
|
|
300
|
+
const override: ThemeOverride = {
|
|
301
|
+
...baseOverride,
|
|
302
|
+
inputs: { displayFont: 'Crimson Pro' },
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
306
|
+
|
|
307
|
+
expect(result.light['--font-display']).toBe('Crimson Pro');
|
|
308
|
+
expect(result.dark['--font-display']).toBe('Crimson Pro');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('handles both colour and font inputs together', () => {
|
|
312
|
+
const override: ThemeOverride = {
|
|
313
|
+
...baseOverride,
|
|
314
|
+
inputs: {
|
|
315
|
+
brandPrimary: '#0052cc',
|
|
316
|
+
displayFont: 'Outfit',
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
321
|
+
|
|
322
|
+
expect(result.light['--color-brand-input']).toBe('#0052cc');
|
|
323
|
+
expect(result.light['--font-display']).toBe('Outfit');
|
|
324
|
+
expect(result.dark['--font-display']).toBe('Outfit');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('overrides only', () => {
|
|
329
|
+
it('maps explicit per-mode overrides to CSS variables', () => {
|
|
330
|
+
const override: ThemeOverride = {
|
|
331
|
+
...baseOverride,
|
|
332
|
+
overrides: {
|
|
333
|
+
light: {
|
|
334
|
+
'color.surface.background': '#fafafa',
|
|
335
|
+
'color.content.primary': '#111111',
|
|
336
|
+
},
|
|
337
|
+
dark: {
|
|
338
|
+
'color.surface.background': '#1a1a1a',
|
|
339
|
+
'color.content.primary': '#eeeeee',
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
345
|
+
|
|
346
|
+
expect(result.light['--color-surface-background']).toBe('#fafafa');
|
|
347
|
+
expect(result.light['--color-content-primary']).toBe('#111111');
|
|
348
|
+
expect(result.dark['--color-surface-background']).toBe('#1a1a1a');
|
|
349
|
+
expect(result.dark['--color-content-primary']).toBe('#eeeeee');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('handles light-only overrides without dark', () => {
|
|
353
|
+
const override: ThemeOverride = {
|
|
354
|
+
...baseOverride,
|
|
355
|
+
overrides: {
|
|
356
|
+
light: {
|
|
357
|
+
'color.surface.background': '#fefefe',
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
363
|
+
|
|
364
|
+
expect(result.light['--color-surface-background']).toBe('#fefefe');
|
|
365
|
+
expect(result.dark['--color-surface-background']).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe('inputs + overrides (collision handling)', () => {
|
|
370
|
+
it('explicit override wins over palette-generated value on collision', () => {
|
|
371
|
+
const override: ThemeOverride = {
|
|
372
|
+
...baseOverride,
|
|
373
|
+
inputs: { brandPrimary: '#0052cc' },
|
|
374
|
+
overrides: {
|
|
375
|
+
light: {
|
|
376
|
+
'color.brand.500': '#custom1',
|
|
377
|
+
},
|
|
378
|
+
dark: {
|
|
379
|
+
'color.brand.500': '#custom2',
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
385
|
+
|
|
386
|
+
expect(result.light['--color-brand-500']).toBe('#custom1');
|
|
387
|
+
expect(result.dark['--color-brand-500']).toBe('#custom2');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('non-colliding palette values coexist with explicit overrides', () => {
|
|
391
|
+
const override: ThemeOverride = {
|
|
392
|
+
...baseOverride,
|
|
393
|
+
inputs: { brandPrimary: '#0052cc' },
|
|
394
|
+
overrides: {
|
|
395
|
+
light: {
|
|
396
|
+
'color.surface.background': '#ffffff',
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
402
|
+
|
|
403
|
+
expect(result.light['--color-brand-input']).toBe('#0052cc');
|
|
404
|
+
expect(result.light['--color-surface-background']).toBe('#ffffff');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('font input survives when override targets different token', () => {
|
|
408
|
+
const override: ThemeOverride = {
|
|
409
|
+
...baseOverride,
|
|
410
|
+
inputs: { displayFont: 'Crimson Pro' },
|
|
411
|
+
overrides: {
|
|
412
|
+
light: {
|
|
413
|
+
'color.surface.background': '#ffffff',
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
419
|
+
|
|
420
|
+
expect(result.light['--font-display']).toBe('Crimson Pro');
|
|
421
|
+
expect(result.light['--color-surface-background']).toBe('#ffffff');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('metadata-only override', () => {
|
|
426
|
+
it('returns empty maps when only $extensions is present', () => {
|
|
427
|
+
const override: ThemeOverride = {
|
|
428
|
+
$extensions: {
|
|
429
|
+
'com.forge.ui.themeOverride': {
|
|
430
|
+
basePreset: 'prestige',
|
|
431
|
+
schemaVersion: '1.0.0',
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
437
|
+
|
|
438
|
+
expect(result.light).toEqual({});
|
|
439
|
+
expect(result.dark).toEqual({});
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe('unknown input keys', () => {
|
|
444
|
+
it('silently ignores input keys not in INPUT_TOKEN_MAP', () => {
|
|
445
|
+
const override: ThemeOverride = {
|
|
446
|
+
...baseOverride,
|
|
447
|
+
inputs: {
|
|
448
|
+
brandPrimary: '#0052cc',
|
|
449
|
+
unknownKey: '#ff0000',
|
|
450
|
+
anotherUnknown: 'SomeFont',
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
455
|
+
|
|
456
|
+
expect(result.light['--color-brand-input']).toBe('#0052cc');
|
|
457
|
+
const allVars = Object.values(result.light);
|
|
458
|
+
expect(allVars).not.toContain('#ff0000');
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
describe('missing runtime map entries', () => {
|
|
463
|
+
it('silently skips override dot-paths not found in the runtime map', () => {
|
|
464
|
+
const override: ThemeOverride = {
|
|
465
|
+
...baseOverride,
|
|
466
|
+
overrides: {
|
|
467
|
+
light: {
|
|
468
|
+
'color.surface.background': '#ffffff',
|
|
469
|
+
'some.unknown.path': '#aaaaaa',
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const result = themeOverrideToUniwindMaps(override, MOCK_RUNTIME_MAP, 'web');
|
|
475
|
+
|
|
476
|
+
expect(result.light['--color-surface-background']).toBe('#ffffff');
|
|
477
|
+
expect(Object.keys(result.light)).toHaveLength(1);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// isSchemaVersionSupported
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
describe('isSchemaVersionSupported', () => {
|
|
487
|
+
it('returns true for supported schema version (1.0.0)', () => {
|
|
488
|
+
const override: ThemeOverride = {
|
|
489
|
+
$extensions: {
|
|
490
|
+
'com.forge.ui.themeOverride': {
|
|
491
|
+
basePreset: 'poise',
|
|
492
|
+
schemaVersion: '1.0.0',
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
expect(isSchemaVersionSupported(override)).toBe(true);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('returns false for unsupported schema version', () => {
|
|
501
|
+
const override: ThemeOverride = {
|
|
502
|
+
$extensions: {
|
|
503
|
+
'com.forge.ui.themeOverride': {
|
|
504
|
+
basePreset: 'poise',
|
|
505
|
+
schemaVersion: '2.0.0',
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
expect(isSchemaVersionSupported(override)).toBe(false);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('returns false for empty string version', () => {
|
|
514
|
+
const override: ThemeOverride = {
|
|
515
|
+
$extensions: {
|
|
516
|
+
'com.forge.ui.themeOverride': {
|
|
517
|
+
basePreset: 'pulse',
|
|
518
|
+
schemaVersion: '',
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
expect(isSchemaVersionSupported(override)).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import prestigePatch from '../runtime/prestige-vs-default.json';
|
|
2
|
+
import pulsePatch from '../runtime/pulse-vs-default.json';
|
|
3
|
+
import defaultRuntimeMap from '../runtime/token-to-css-var.json';
|
|
4
|
+
import type { Platform, Preset, ThemeOverride, TokenGroup } from './types';
|
|
5
|
+
import {
|
|
6
|
+
isSchemaVersionSupported,
|
|
7
|
+
presetPatchToUniwindMaps,
|
|
8
|
+
themeOverrideToUniwindMaps,
|
|
9
|
+
type CssVariableMaps,
|
|
10
|
+
type RuntimeMap,
|
|
11
|
+
} from './theming';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Constants
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** The build-time default preset. Apps ship with Poise baked into CSS. */
|
|
18
|
+
export const DEFAULT_PRESET: Preset = 'poise';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Preset patch lookup
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const PRESET_PATCHES: Record<Preset, TokenGroup | null> = {
|
|
25
|
+
poise: null,
|
|
26
|
+
prestige: prestigePatch,
|
|
27
|
+
pulse: pulsePatch,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Platform detection
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function detectPlatform(): Platform {
|
|
35
|
+
try {
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
|
+
const { Platform: RNPlatform } = require('react-native');
|
|
38
|
+
const os = RNPlatform.OS as string;
|
|
39
|
+
if (os === 'ios' || os === 'android') return os;
|
|
40
|
+
return 'web';
|
|
41
|
+
} catch {
|
|
42
|
+
return 'web';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Result type
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export type ApplyThemeOverrideResult =
|
|
51
|
+
| { applied: true; light: Record<string, string>; dark: Record<string, string> }
|
|
52
|
+
| { applied: false; reason: 'unsupported_schema_version' | 'no_theme_changes' };
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Options
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export interface ApplyThemeOverrideOptions {
|
|
59
|
+
runtimeMap?: RuntimeMap;
|
|
60
|
+
runtimePlatform?: Platform;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Orchestrator
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Apply a `ThemeOverride` end-to-end: schema version gate, preset patch
|
|
69
|
+
* selection, palette generation, and `Uniwind.updateCSSVariables` calls.
|
|
70
|
+
*
|
|
71
|
+
* Encapsulates the full runtime theme application sequence so consuming
|
|
72
|
+
* apps don't need to orchestrate lower-level utilities or import Uniwind
|
|
73
|
+
* directly.
|
|
74
|
+
*/
|
|
75
|
+
export function applyThemeOverride(
|
|
76
|
+
override: ThemeOverride,
|
|
77
|
+
options: ApplyThemeOverrideOptions = {},
|
|
78
|
+
): ApplyThemeOverrideResult {
|
|
79
|
+
const { runtimeMap = defaultRuntimeMap, runtimePlatform = detectPlatform() } = options;
|
|
80
|
+
|
|
81
|
+
// --- Schema version gate ---
|
|
82
|
+
if (!isSchemaVersionSupported(override)) {
|
|
83
|
+
return { applied: false, reason: 'unsupported_schema_version' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const basePreset = override.$extensions['com.forge.ui.themeOverride'].basePreset;
|
|
87
|
+
const hasInputs = override.inputs !== undefined && Object.keys(override.inputs).length > 0;
|
|
88
|
+
const hasOverrides =
|
|
89
|
+
override.overrides !== undefined && Object.keys(override.overrides).length > 0;
|
|
90
|
+
|
|
91
|
+
// --- Metadata-only check ---
|
|
92
|
+
const isDefaultPreset = basePreset === DEFAULT_PRESET;
|
|
93
|
+
if (isDefaultPreset && !hasInputs && !hasOverrides) {
|
|
94
|
+
return { applied: false, reason: 'no_theme_changes' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Preset patch step ---
|
|
98
|
+
let presetMaps: CssVariableMaps = { light: {}, dark: {} };
|
|
99
|
+
if (!isDefaultPreset) {
|
|
100
|
+
const patch = PRESET_PATCHES[basePreset];
|
|
101
|
+
if (patch) {
|
|
102
|
+
presetMaps = presetPatchToUniwindMaps(patch, runtimeMap, runtimePlatform);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- FI override step ---
|
|
107
|
+
let overrideMaps: CssVariableMaps = { light: {}, dark: {} };
|
|
108
|
+
if (hasInputs || hasOverrides) {
|
|
109
|
+
overrideMaps = themeOverrideToUniwindMaps(override, runtimeMap);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- Merge per mode (FI wins on collision) ---
|
|
113
|
+
const mergedLight = { ...presetMaps.light, ...overrideMaps.light };
|
|
114
|
+
const mergedDark = { ...presetMaps.dark, ...overrideMaps.dark };
|
|
115
|
+
|
|
116
|
+
// --- Non-default preset with no FI changes still needs patch application ---
|
|
117
|
+
if (Object.keys(mergedLight).length === 0 && Object.keys(mergedDark).length === 0) {
|
|
118
|
+
return { applied: false, reason: 'no_theme_changes' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Apply via Uniwind ---
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
124
|
+
const { Uniwind } = require('uniwind');
|
|
125
|
+
if (Object.keys(mergedLight).length > 0) {
|
|
126
|
+
Uniwind.updateCSSVariables('light', mergedLight);
|
|
127
|
+
}
|
|
128
|
+
if (Object.keys(mergedDark).length > 0) {
|
|
129
|
+
Uniwind.updateCSSVariables('dark', mergedDark);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(
|
|
133
|
+
'applyThemeOverride requires "uniwind" to be installed. ' +
|
|
134
|
+
'Add it as a dependency in your app.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { applied: true, light: mergedLight, dark: mergedDark };
|
|
139
|
+
}
|