@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
@@ -0,0 +1,488 @@
1
+ import { applyThemeOverride, DEFAULT_PRESET } from '../applyThemeOverride';
2
+ import type { ApplyThemeOverrideResult } from '../applyThemeOverride';
3
+ import type { RuntimeMap } from '../theming';
4
+ import type { ThemeOverride } from '../types';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Mocks
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const mockUpdateCSSVariables = jest.fn();
11
+
12
+ jest.mock('uniwind', () => ({
13
+ Uniwind: {
14
+ updateCSSVariables: (...args: unknown[]) => mockUpdateCSSVariables(...args),
15
+ },
16
+ }));
17
+
18
+ jest.mock('react-native', () => ({
19
+ Platform: { OS: 'ios' },
20
+ }));
21
+
22
+ jest.mock(
23
+ '../../runtime/prestige-vs-default.json',
24
+ () => ({
25
+ modes: {
26
+ light: {
27
+ color: {
28
+ surface: {
29
+ background: { $type: 'color', $value: '#faf8f5' },
30
+ },
31
+ },
32
+ },
33
+ dark: {
34
+ color: {
35
+ surface: {
36
+ background: { $type: 'color', $value: '#1c1917' },
37
+ },
38
+ },
39
+ },
40
+ },
41
+ platform: {
42
+ ios: {
43
+ font: {
44
+ sans: { $type: 'fontFamily', $value: 'SF Pro Display' },
45
+ },
46
+ },
47
+ web: {
48
+ font: {
49
+ sans: { $type: 'fontFamily', $value: 'Libre Franklin' },
50
+ },
51
+ },
52
+ },
53
+ }),
54
+ { virtual: true },
55
+ );
56
+
57
+ jest.mock(
58
+ '../../runtime/pulse-vs-default.json',
59
+ () => ({
60
+ modes: {
61
+ light: {
62
+ color: {
63
+ surface: {
64
+ background: { $type: 'color', $value: '#f0f4ff' },
65
+ },
66
+ },
67
+ },
68
+ dark: {
69
+ color: {
70
+ surface: {
71
+ background: { $type: 'color', $value: '#0a0e1a' },
72
+ },
73
+ },
74
+ },
75
+ },
76
+ }),
77
+ { virtual: true },
78
+ );
79
+
80
+ jest.mock(
81
+ '../../runtime/token-to-css-var.json',
82
+ () => ({
83
+ 'color.brand.50': '--color-brand-50',
84
+ 'color.brand.100': '--color-brand-100',
85
+ 'color.brand.200': '--color-brand-200',
86
+ 'color.brand.300': '--color-brand-300',
87
+ 'color.brand.400': '--color-brand-400',
88
+ 'color.brand.500': '--color-brand-500',
89
+ 'color.brand.600': '--color-brand-600',
90
+ 'color.brand.700': '--color-brand-700',
91
+ 'color.brand.800': '--color-brand-800',
92
+ 'color.brand.900': '--color-brand-900',
93
+ 'color.brand.950': '--color-brand-950',
94
+ 'color.brand.input': '--color-brand-input',
95
+ 'color.accent.50': '--color-accent-50',
96
+ 'color.accent.100': '--color-accent-100',
97
+ 'color.accent.500': '--color-accent-500',
98
+ 'color.accent.input': '--color-accent-input',
99
+ 'font.display': '--font-display',
100
+ 'modes.light.color.surface.background': '--color-surface-background',
101
+ 'modes.dark.color.surface.background': '--color-surface-background',
102
+ 'modes.light.color.content.primary': '--color-content-primary',
103
+ 'modes.dark.color.content.primary': '--color-content-primary',
104
+ 'modes.light.font.display': '--font-display',
105
+ 'modes.dark.font.display': '--font-display',
106
+ 'platform.web.font.sans': '--font-sans',
107
+ 'platform.ios.font.sans': '--font-sans',
108
+ 'platform.android.font.sans': '--font-sans',
109
+ }),
110
+ { virtual: true },
111
+ );
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Test runtime map (used for explicit runtimeMap option tests)
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const TEST_RUNTIME_MAP: RuntimeMap = {
118
+ 'color.brand.50': '--color-brand-50',
119
+ 'color.brand.100': '--color-brand-100',
120
+ 'color.brand.200': '--color-brand-200',
121
+ 'color.brand.300': '--color-brand-300',
122
+ 'color.brand.400': '--color-brand-400',
123
+ 'color.brand.500': '--color-brand-500',
124
+ 'color.brand.600': '--color-brand-600',
125
+ 'color.brand.700': '--color-brand-700',
126
+ 'color.brand.800': '--color-brand-800',
127
+ 'color.brand.900': '--color-brand-900',
128
+ 'color.brand.950': '--color-brand-950',
129
+ 'color.brand.input': '--color-brand-input',
130
+ 'color.accent.50': '--color-accent-50',
131
+ 'color.accent.100': '--color-accent-100',
132
+ 'color.accent.500': '--color-accent-500',
133
+ 'color.accent.input': '--color-accent-input',
134
+ 'font.display': '--font-display',
135
+ 'modes.light.color.surface.background': '--color-surface-background',
136
+ 'modes.dark.color.surface.background': '--color-surface-background',
137
+ 'modes.light.color.content.primary': '--color-content-primary',
138
+ 'modes.dark.color.content.primary': '--color-content-primary',
139
+ 'modes.light.font.display': '--font-display',
140
+ 'modes.dark.font.display': '--font-display',
141
+ 'platform.web.font.sans': '--font-sans',
142
+ 'platform.ios.font.sans': '--font-sans',
143
+ 'platform.android.font.sans': '--font-sans',
144
+ };
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Helpers
148
+ // ---------------------------------------------------------------------------
149
+
150
+ beforeEach(() => {
151
+ mockUpdateCSSVariables.mockClear();
152
+ });
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // DEFAULT_PRESET
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe('DEFAULT_PRESET', () => {
159
+ it('equals "poise"', () => {
160
+ expect(DEFAULT_PRESET).toBe('poise');
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // applyThemeOverride
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe('applyThemeOverride', () => {
169
+ describe('successful apply (default preset + override)', () => {
170
+ it('applies brand colour override with default preset', () => {
171
+ const override: ThemeOverride = {
172
+ $extensions: {
173
+ 'com.forge.ui.themeOverride': {
174
+ basePreset: 'poise',
175
+ schemaVersion: '1.0.0',
176
+ },
177
+ },
178
+ inputs: { brandPrimary: '#0052cc' },
179
+ };
180
+
181
+ const result = applyThemeOverride(override, {
182
+ runtimeMap: TEST_RUNTIME_MAP,
183
+ runtimePlatform: 'web',
184
+ });
185
+
186
+ expect(result.applied).toBe(true);
187
+ if (result.applied) {
188
+ expect(result.light['--color-brand-input']).toBe('#0052cc');
189
+ expect(result.dark['--color-brand-input']).toBe('#0052cc');
190
+ expect(result.light['--color-brand-500']).toBeDefined();
191
+ }
192
+ expect(mockUpdateCSSVariables).toHaveBeenCalledTimes(2);
193
+ expect(mockUpdateCSSVariables).toHaveBeenCalledWith('light', expect.any(Object));
194
+ expect(mockUpdateCSSVariables).toHaveBeenCalledWith('dark', expect.any(Object));
195
+ });
196
+
197
+ it('applies font input override', () => {
198
+ const override: ThemeOverride = {
199
+ $extensions: {
200
+ 'com.forge.ui.themeOverride': {
201
+ basePreset: 'poise',
202
+ schemaVersion: '1.0.0',
203
+ },
204
+ },
205
+ inputs: { displayFont: 'Crimson Pro' },
206
+ };
207
+
208
+ const result = applyThemeOverride(override, {
209
+ runtimeMap: TEST_RUNTIME_MAP,
210
+ runtimePlatform: 'web',
211
+ });
212
+
213
+ expect(result.applied).toBe(true);
214
+ if (result.applied) {
215
+ expect(result.light['--font-display']).toBe('Crimson Pro');
216
+ expect(result.dark['--font-display']).toBe('Crimson Pro');
217
+ }
218
+ });
219
+
220
+ it('applies explicit per-mode overrides', () => {
221
+ const override: ThemeOverride = {
222
+ $extensions: {
223
+ 'com.forge.ui.themeOverride': {
224
+ basePreset: 'poise',
225
+ schemaVersion: '1.0.0',
226
+ },
227
+ },
228
+ overrides: {
229
+ light: { 'color.surface.background': '#fafafa' },
230
+ dark: { 'color.surface.background': '#1a1a1a' },
231
+ },
232
+ };
233
+
234
+ const result = applyThemeOverride(override, {
235
+ runtimeMap: TEST_RUNTIME_MAP,
236
+ runtimePlatform: 'web',
237
+ });
238
+
239
+ expect(result.applied).toBe(true);
240
+ if (result.applied) {
241
+ expect(result.light['--color-surface-background']).toBe('#fafafa');
242
+ expect(result.dark['--color-surface-background']).toBe('#1a1a1a');
243
+ }
244
+ });
245
+ });
246
+
247
+ describe('non-default preset patch + override', () => {
248
+ it('applies prestige preset patch before FI override', () => {
249
+ const override: ThemeOverride = {
250
+ $extensions: {
251
+ 'com.forge.ui.themeOverride': {
252
+ basePreset: 'prestige',
253
+ schemaVersion: '1.0.0',
254
+ },
255
+ },
256
+ inputs: { brandPrimary: '#8b5cf6' },
257
+ };
258
+
259
+ const result = applyThemeOverride(override, {
260
+ runtimeMap: TEST_RUNTIME_MAP,
261
+ runtimePlatform: 'ios',
262
+ });
263
+
264
+ expect(result.applied).toBe(true);
265
+ if (result.applied) {
266
+ expect(result.light['--color-surface-background']).toBe('#faf8f5');
267
+ expect(result.dark['--color-surface-background']).toBe('#1c1917');
268
+ expect(result.light['--color-brand-input']).toBe('#8b5cf6');
269
+ expect(result.light['--font-sans']).toBe('SF Pro Display');
270
+ }
271
+ });
272
+
273
+ it('FI override wins over preset patch on collision', () => {
274
+ const override: ThemeOverride = {
275
+ $extensions: {
276
+ 'com.forge.ui.themeOverride': {
277
+ basePreset: 'prestige',
278
+ schemaVersion: '1.0.0',
279
+ },
280
+ },
281
+ overrides: {
282
+ light: { 'color.surface.background': '#custom1' },
283
+ dark: { 'color.surface.background': '#custom2' },
284
+ },
285
+ };
286
+
287
+ const result = applyThemeOverride(override, {
288
+ runtimeMap: TEST_RUNTIME_MAP,
289
+ runtimePlatform: 'web',
290
+ });
291
+
292
+ expect(result.applied).toBe(true);
293
+ if (result.applied) {
294
+ expect(result.light['--color-surface-background']).toBe('#custom1');
295
+ expect(result.dark['--color-surface-background']).toBe('#custom2');
296
+ }
297
+ });
298
+
299
+ it('applies pulse preset patch with metadata-only override', () => {
300
+ const override: ThemeOverride = {
301
+ $extensions: {
302
+ 'com.forge.ui.themeOverride': {
303
+ basePreset: 'pulse',
304
+ schemaVersion: '1.0.0',
305
+ },
306
+ },
307
+ };
308
+
309
+ const result = applyThemeOverride(override, {
310
+ runtimeMap: TEST_RUNTIME_MAP,
311
+ runtimePlatform: 'web',
312
+ });
313
+
314
+ expect(result.applied).toBe(true);
315
+ if (result.applied) {
316
+ expect(result.light['--color-surface-background']).toBe('#f0f4ff');
317
+ expect(result.dark['--color-surface-background']).toBe('#0a0e1a');
318
+ }
319
+ });
320
+ });
321
+
322
+ describe('schema version skip', () => {
323
+ it('returns early without calling Uniwind when schema version is unsupported', () => {
324
+ const override: ThemeOverride = {
325
+ $extensions: {
326
+ 'com.forge.ui.themeOverride': {
327
+ basePreset: 'poise',
328
+ schemaVersion: '99.0.0',
329
+ },
330
+ },
331
+ inputs: { brandPrimary: '#ff0000' },
332
+ };
333
+
334
+ const result = applyThemeOverride(override, {
335
+ runtimeMap: TEST_RUNTIME_MAP,
336
+ runtimePlatform: 'web',
337
+ });
338
+
339
+ expect(result.applied).toBe(false);
340
+ if (!result.applied) {
341
+ expect(result.reason).toBe('unsupported_schema_version');
342
+ }
343
+ expect(mockUpdateCSSVariables).not.toHaveBeenCalled();
344
+ });
345
+ });
346
+
347
+ describe('metadata-only override no-op', () => {
348
+ it('does not call Uniwind when override has only $extensions with default preset', () => {
349
+ const override: ThemeOverride = {
350
+ $extensions: {
351
+ 'com.forge.ui.themeOverride': {
352
+ basePreset: 'poise',
353
+ schemaVersion: '1.0.0',
354
+ },
355
+ },
356
+ };
357
+
358
+ const result = applyThemeOverride(override, {
359
+ runtimeMap: TEST_RUNTIME_MAP,
360
+ runtimePlatform: 'web',
361
+ });
362
+
363
+ expect(result.applied).toBe(false);
364
+ if (!result.applied) {
365
+ expect(result.reason).toBe('no_theme_changes');
366
+ }
367
+ expect(mockUpdateCSSVariables).not.toHaveBeenCalled();
368
+ });
369
+
370
+ it('does not call Uniwind when override has empty inputs/overrides with default preset', () => {
371
+ const override: ThemeOverride = {
372
+ $extensions: {
373
+ 'com.forge.ui.themeOverride': {
374
+ basePreset: 'poise',
375
+ schemaVersion: '1.0.0',
376
+ },
377
+ },
378
+ inputs: {},
379
+ overrides: {},
380
+ };
381
+
382
+ const result = applyThemeOverride(override, {
383
+ runtimeMap: TEST_RUNTIME_MAP,
384
+ runtimePlatform: 'web',
385
+ });
386
+
387
+ expect(result.applied).toBe(false);
388
+ if (!result.applied) {
389
+ expect(result.reason).toBe('no_theme_changes');
390
+ }
391
+ expect(mockUpdateCSSVariables).not.toHaveBeenCalled();
392
+ });
393
+ });
394
+
395
+ describe('default runtimeMap', () => {
396
+ it('uses bundled token-to-css-var.json when runtimeMap is not provided', () => {
397
+ const override: ThemeOverride = {
398
+ $extensions: {
399
+ 'com.forge.ui.themeOverride': {
400
+ basePreset: 'poise',
401
+ schemaVersion: '1.0.0',
402
+ },
403
+ },
404
+ inputs: { brandPrimary: '#0052cc' },
405
+ };
406
+
407
+ const result = applyThemeOverride(override, { runtimePlatform: 'web' });
408
+
409
+ expect(result.applied).toBe(true);
410
+ if (result.applied) {
411
+ expect(result.light['--color-brand-input']).toBe('#0052cc');
412
+ }
413
+ });
414
+ });
415
+
416
+ describe('platform auto-detection fallback', () => {
417
+ it('auto-detects platform from Platform.OS when runtimePlatform is not provided', () => {
418
+ const override: ThemeOverride = {
419
+ $extensions: {
420
+ 'com.forge.ui.themeOverride': {
421
+ basePreset: 'prestige',
422
+ schemaVersion: '1.0.0',
423
+ },
424
+ },
425
+ inputs: { brandPrimary: '#0052cc' },
426
+ };
427
+
428
+ const result = applyThemeOverride(override) as Extract<
429
+ ApplyThemeOverrideResult,
430
+ { applied: true }
431
+ >;
432
+
433
+ expect(result.applied).toBe(true);
434
+ expect(result.light['--font-sans']).toBe('SF Pro Display');
435
+ });
436
+
437
+ it('falls back to web when react-native is unavailable', () => {
438
+ jest.resetModules();
439
+ jest.doMock('react-native', () => {
440
+ throw new Error('Cannot find module');
441
+ });
442
+
443
+ // Re-import to get fresh module with the new mock
444
+ const { applyThemeOverride: freshApply } = jest.requireActual('../applyThemeOverride');
445
+
446
+ const override: ThemeOverride = {
447
+ $extensions: {
448
+ 'com.forge.ui.themeOverride': {
449
+ basePreset: 'prestige',
450
+ schemaVersion: '1.0.0',
451
+ },
452
+ },
453
+ inputs: { brandPrimary: '#0052cc' },
454
+ };
455
+
456
+ const result = freshApply(override, { runtimeMap: TEST_RUNTIME_MAP });
457
+
458
+ expect(result.applied).toBe(true);
459
+ if (result.applied) {
460
+ expect(result.light['--font-sans']).toBe('Libre Franklin');
461
+ }
462
+ });
463
+ });
464
+
465
+ describe('when default preset is skipped', () => {
466
+ it('does not apply preset patch when basePreset is poise', () => {
467
+ const override: ThemeOverride = {
468
+ $extensions: {
469
+ 'com.forge.ui.themeOverride': {
470
+ basePreset: 'poise',
471
+ schemaVersion: '1.0.0',
472
+ },
473
+ },
474
+ inputs: { brandPrimary: '#0052cc' },
475
+ };
476
+
477
+ const result = applyThemeOverride(override, {
478
+ runtimeMap: TEST_RUNTIME_MAP,
479
+ runtimePlatform: 'web',
480
+ });
481
+
482
+ expect(result.applied).toBe(true);
483
+ if (result.applied) {
484
+ expect(result.light['--color-surface-background']).toBeUndefined();
485
+ }
486
+ });
487
+ });
488
+ });
@@ -0,0 +1,202 @@
1
+ import { generateColorScale, generatePalettesFromInputs } from '../palette';
2
+
3
+ const PALETTE_STEPS = [
4
+ '50',
5
+ '100',
6
+ '200',
7
+ '300',
8
+ '400',
9
+ '500',
10
+ '600',
11
+ '700',
12
+ '800',
13
+ '900',
14
+ '950',
15
+ ] as const;
16
+
17
+ /**
18
+ * Calculate WCAG 2.x relative luminance from a hex string.
19
+ * @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
20
+ */
21
+ function relativeLuminance(hex: string): number {
22
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
23
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
24
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
25
+
26
+ const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
27
+
28
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
29
+ }
30
+
31
+ /** WCAG 2.x contrast ratio between two hex colours. */
32
+ function contrastRatio(hex1: string, hex2: string): number {
33
+ const l1 = relativeLuminance(hex1);
34
+ const l2 = relativeLuminance(hex2);
35
+ const lighter = Math.max(l1, l2);
36
+ const darker = Math.min(l1, l2);
37
+ return (lighter + 0.05) / (darker + 0.05);
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // generateColorScale
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe('generateColorScale', () => {
45
+ describe('valid hex input → correct 11-step output structure', () => {
46
+ it('returns all 11 contrast steps plus the input key for brand', () => {
47
+ const result = generateColorScale('#081728', 'brand');
48
+
49
+ for (const step of PALETTE_STEPS) {
50
+ expect(`color.brand.${step}` in result).toBe(true);
51
+ }
52
+ expect('color.brand.input' in result).toBe(true);
53
+ expect(Object.keys(result)).toHaveLength(12);
54
+ });
55
+
56
+ it('returns all 11 contrast steps plus the input key for accent', () => {
57
+ const result = generateColorScale('#ffcf23', 'accent');
58
+
59
+ for (const step of PALETTE_STEPS) {
60
+ expect(`color.accent.${step}` in result).toBe(true);
61
+ }
62
+ expect('color.accent.input' in result).toBe(true);
63
+ expect(Object.keys(result)).toHaveLength(12);
64
+ });
65
+
66
+ it('all generated values are valid 7-character hex strings', () => {
67
+ const result = generateColorScale('#0052cc', 'brand');
68
+
69
+ for (const value of Object.values(result)) {
70
+ expect(value).toMatch(/^#[0-9a-f]{6}$/);
71
+ }
72
+ });
73
+ });
74
+
75
+ describe('input preservation', () => {
76
+ it('preserves the original hex (7-char) verbatim in the input key', () => {
77
+ const result = generateColorScale('#081728', 'brand');
78
+ expect(result['color.brand.input']).toBe('#081728');
79
+ });
80
+
81
+ it('normalises shorthand hex (#RGB → #RRGGBB) in the input key', () => {
82
+ const result = generateColorScale('#F00', 'brand');
83
+ expect(result['color.brand.input']).toBe('#ff0000');
84
+ });
85
+
86
+ it('lowercases the input hex', () => {
87
+ const result = generateColorScale('#AABBCC', 'accent');
88
+ expect(result['color.accent.input']).toBe('#aabbcc');
89
+ });
90
+ });
91
+
92
+ describe('invalid hex handling', () => {
93
+ it('throws for a string without leading #', () => {
94
+ expect(() => generateColorScale('081728', 'brand')).toThrow(TypeError);
95
+ expect(() => generateColorScale('081728', 'brand')).toThrow(/must be a 4-character/);
96
+ });
97
+
98
+ it('throws for wrong-length hex', () => {
99
+ expect(() => generateColorScale('#0817', 'brand')).toThrow(TypeError);
100
+ expect(() => generateColorScale('#08172', 'brand')).toThrow(TypeError);
101
+ expect(() => generateColorScale('#08172800', 'brand')).toThrow(TypeError);
102
+ });
103
+
104
+ it('throws for non-hex characters', () => {
105
+ expect(() => generateColorScale('#GGHHII', 'brand')).toThrow(TypeError);
106
+ });
107
+
108
+ it('throws for empty string', () => {
109
+ expect(() => generateColorScale('', 'brand')).toThrow(TypeError);
110
+ });
111
+
112
+ it('throws for non-string input', () => {
113
+ expect(() => generateColorScale(123 as unknown as string, 'brand')).toThrow(TypeError);
114
+ });
115
+ });
116
+
117
+ describe('contrast ratio spot-checks', () => {
118
+ const WHITE = '#ffffff';
119
+
120
+ it('brand palette: step 700 meets WCAG AA for normal text (≥ 4.5:1)', () => {
121
+ const result = generateColorScale('#081728', 'brand');
122
+ const ratio = contrastRatio(result['color.brand.700'], WHITE);
123
+ expect(ratio).toBeGreaterThanOrEqual(4.5);
124
+ });
125
+
126
+ it('brand palette: steps are ordered from lightest (50) to darkest (950)', () => {
127
+ const result = generateColorScale('#081728', 'brand');
128
+ const luminances = PALETTE_STEPS.map((step) =>
129
+ relativeLuminance(result[`color.brand.${step}`]),
130
+ );
131
+ for (let i = 1; i < luminances.length; i++) {
132
+ expect(luminances[i]).toBeLessThanOrEqual(luminances[i - 1]);
133
+ }
134
+ });
135
+
136
+ it('accent palette: step 700 meets WCAG AA for normal text (≥ 4.5:1)', () => {
137
+ const result = generateColorScale('#ffcf23', 'accent');
138
+ const ratio = contrastRatio(result['color.accent.700'], WHITE);
139
+ expect(ratio).toBeGreaterThanOrEqual(4.5);
140
+ });
141
+
142
+ it('accent palette: step 50 has very low contrast against white (< 1.5:1)', () => {
143
+ const result = generateColorScale('#ffcf23', 'accent');
144
+ const ratio = contrastRatio(result['color.accent.50'], WHITE);
145
+ expect(ratio).toBeLessThan(1.5);
146
+ });
147
+ });
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // generatePalettesFromInputs
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe('generatePalettesFromInputs', () => {
155
+ it('generates palettes for both brand and accent colour inputs', () => {
156
+ const result = generatePalettesFromInputs({
157
+ brandPrimary: '#0052cc',
158
+ accentPrimary: '#FF8C42',
159
+ });
160
+
161
+ for (const step of PALETTE_STEPS) {
162
+ expect(`color.brand.${step}` in result).toBe(true);
163
+ expect(`color.accent.${step}` in result).toBe(true);
164
+ }
165
+ expect(result['color.brand.input']).toBe('#0052cc');
166
+ expect(result['color.accent.input']).toBe('#ff8c42');
167
+ });
168
+
169
+ it('skips non-colour inputs (e.g. displayFont)', () => {
170
+ const result = generatePalettesFromInputs({
171
+ brandPrimary: '#0052cc',
172
+ displayFont: 'Poppins',
173
+ });
174
+
175
+ expect('color.brand.input' in result).toBe(true);
176
+ expect(Object.keys(result).every((k) => k.startsWith('color.'))).toBe(true);
177
+ });
178
+
179
+ it('skips colour-input keys with non-string values', () => {
180
+ const result = generatePalettesFromInputs({
181
+ brandPrimary: 42 as unknown as string,
182
+ });
183
+
184
+ expect(Object.keys(result)).toHaveLength(0);
185
+ });
186
+
187
+ it('returns an empty object when inputs is empty', () => {
188
+ const result = generatePalettesFromInputs({});
189
+ expect(result).toEqual({});
190
+ });
191
+
192
+ it('generates only brand palette when only brandPrimary is provided', () => {
193
+ const result = generatePalettesFromInputs({
194
+ brandPrimary: '#081728',
195
+ });
196
+
197
+ expect(Object.keys(result)).toHaveLength(12);
198
+ for (const key of Object.keys(result)) {
199
+ expect(key).toMatch(/^color\.brand\./);
200
+ }
201
+ });
202
+ });