@micha.bigler/ui-core-micha 3.10.0 → 3.11.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.
@@ -1,11 +1,16 @@
1
1
  import './fonts';
2
2
 
3
- import { createTheme, darken, getContrastRatio } from '@mui/material/styles';
3
+ import { createTheme, darken, decomposeColor, getContrastRatio, lighten } from '@mui/material/styles';
4
4
 
5
5
  import {
6
6
  BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS,
7
+ BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK,
7
8
  BASELINE_PALETTE,
9
+ BASELINE_PALETTE_DARK,
8
10
  BASELINE_STATIC,
11
+ DARK_OVERLAY_SHADOW,
12
+ DARK_OVERLAY_SURFACE,
13
+ DARK_SCRIM,
9
14
  MOTION,
10
15
  withMainShades,
11
16
  } from './tokens';
@@ -42,6 +47,21 @@ function clearsContrast(colour, surfaces) {
42
47
  return surfaces.every((surface) => getContrastRatio(colour, surface) >= 3);
43
48
  }
44
49
 
50
+ function normalizeColour(colour) {
51
+ const match = colour.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
52
+ if (!match) return colour;
53
+ return `#${match.slice(1).map((channel) => Number(channel).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
54
+ }
55
+
56
+ function lightenColour(colour, coefficient) {
57
+ const normalized = normalizeColour(colour);
58
+ const match = normalized.match(/^#([\da-f]{6})$/i);
59
+ if (!match) return normalizeColour(lighten(colour, coefficient));
60
+ const channels = [0, 2, 4].map((offset) => parseInt(match[1].slice(offset, offset + 2), 16));
61
+ return `#${channels.map((channel) => Math.round(channel + (255 - channel) * coefficient)
62
+ .toString(16).padStart(2, '0')).join('').toUpperCase()}`;
63
+ }
64
+
45
65
  // Darkened until it clears 3:1 against BOTH '#FFFFFF' and background.default
46
66
  // -- the exact two surfaces assertThemeComplete's contrastFindings checks --
47
67
  // so deriving against background.paper alone (which need not be white for an
@@ -55,6 +75,16 @@ function deriveFocusColour(primary, surfaces) {
55
75
  return focus;
56
76
  }
57
77
 
78
+ function deriveLightenedColour(original, surfaces) {
79
+ if (clearsContrast(original, surfaces)) return original;
80
+ let colour = original;
81
+ for (let coefficient = 0.05; !clearsContrast(colour, surfaces); coefficient += 0.05) {
82
+ colour = lightenColour(original, Math.min(coefficient, 0.9));
83
+ if (coefficient >= 0.9) break;
84
+ }
85
+ return colour;
86
+ }
87
+
58
88
  function clearsPageContrast(colour, surfaces, threshold) {
59
89
  return surfaces.every((surface) => {
60
90
  const ratio = calculateContrastRatio(colour, surface);
@@ -73,6 +103,35 @@ function deriveDarkenedPageColour(original, surfaces, threshold) {
73
103
  return colour;
74
104
  }
75
105
 
106
+ function deriveLightenedPageColour(original, surfaces, threshold) {
107
+ if (clearsPageContrast(original, surfaces, threshold)) return original;
108
+
109
+ let colour = original;
110
+ for (let coefficient = 0.05; !clearsPageContrast(colour, surfaces, threshold); coefficient += 0.05) {
111
+ colour = lightenColour(original, Math.min(coefficient, 0.9));
112
+ if (coefficient >= 0.9) break;
113
+ }
114
+ return colour;
115
+ }
116
+
117
+ // ink.primary may be supplied as hex or rgb(); decomposeColor normalises both.
118
+ function inkChannels(ink) {
119
+ return decomposeColor(ink).values.slice(0, 3).map((value) => Math.round(value));
120
+ }
121
+
122
+ // 0.02 steps: the frozen sheet value (0.38) is the first even step that clears
123
+ // 3:1; a 0.01 search would land on 0.37 and drift from the frozen baseline.
124
+ function deriveDarkAlphaColour(ink, surfaces) {
125
+ const [red, green, blue] = inkChannels(ink);
126
+ let alpha = 0;
127
+ while (alpha <= 1) {
128
+ const colour = `rgba(${red},${green},${blue},${alpha.toFixed(2)})`;
129
+ if (clearsPageContrast(colour, surfaces, 3)) return colour;
130
+ alpha = Number((alpha + 0.02).toFixed(2));
131
+ }
132
+ return `rgba(${red},${green},${blue},1)`;
133
+ }
134
+
76
135
  const RGBA_PATTERN = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/;
77
136
 
78
137
  function deriveAlphaPageColour(original, surfaces, threshold) {
@@ -92,7 +151,7 @@ function deriveAlphaPageColour(original, surfaces, threshold) {
92
151
  return colour;
93
152
  }
94
153
 
95
- function createPaletteAwareComponents(palette) {
154
+ function createPaletteAwareComponents(palette, isDark) {
96
155
  const stateTransition = `border-color ${MOTION.duration.fast}ms ${MOTION.easing.state}, color ${MOTION.duration.fast}ms ${MOTION.easing.state}, background-color ${MOTION.duration.fast}ms ${MOTION.easing.state}`;
97
156
  const autofill = {
98
157
  '&:-webkit-autofill': {
@@ -117,7 +176,7 @@ function createPaletteAwareComponents(palette) {
117
176
  ]),
118
177
  );
119
178
 
120
- return {
179
+ const components = {
121
180
  MuiButton: {
122
181
  styleOverrides: {
123
182
  root: { transition: stateTransition },
@@ -199,6 +258,22 @@ function createPaletteAwareComponents(palette) {
199
258
  styleOverrides: alertStyles,
200
259
  },
201
260
  };
261
+
262
+ if (isDark) {
263
+ for (const component of ['MuiDialog', 'MuiMenu', 'MuiPopover', 'MuiDrawer']) {
264
+ components[component] = {
265
+ styleOverrides: {
266
+ paper: {
267
+ backgroundColor: DARK_OVERLAY_SURFACE,
268
+ boxShadow: DARK_OVERLAY_SHADOW,
269
+ },
270
+ },
271
+ };
272
+ }
273
+ components.MuiBackdrop = { styleOverrides: { root: { backgroundColor: DARK_SCRIM } } };
274
+ }
275
+
276
+ return components;
202
277
  }
203
278
 
204
279
  /**
@@ -212,19 +287,29 @@ export function createAppTheme(appConfig = {}) {
212
287
  }
213
288
  validateStyleOverrides(appConfig.components);
214
289
 
290
+ const isDark = appConfig.palette.mode === 'dark';
291
+ const baselinePalette = isDark ? BASELINE_PALETTE_DARK : BASELINE_PALETTE;
292
+
215
293
  const paletteTheme = createTheme(
216
294
  { palette: appConfig.palette },
217
- { palette: BASELINE_PALETTE },
295
+ { palette: baselinePalette },
218
296
  { palette: appConfig.palette },
219
297
  );
220
298
  const finalPalette = paletteTheme.palette;
221
- const contrastSurfaces = ['#FFFFFF', finalPalette.background.default];
299
+ const contrastSurfaces = isDark
300
+ ? [finalPalette.background.default, finalPalette.background.paper]
301
+ : ['#FFFFFF', finalPalette.background.default];
222
302
  const derivedStatusEntries = {};
223
303
  for (const status of ['success', 'warning', 'error', 'info']) {
224
304
  if (appConfig.palette?.[status]?.main !== undefined) continue;
225
305
 
226
306
  const current = finalPalette[status];
227
- const main = deriveDarkenedPageColour(current.main, contrastSurfaces, 4.5);
307
+ const statusSurfaces = isDark
308
+ ? [finalPalette.background.default, current.bg]
309
+ : contrastSurfaces;
310
+ const main = isDark
311
+ ? deriveLightenedPageColour(current.main, statusSurfaces, 4.5)
312
+ : deriveDarkenedPageColour(current.main, statusSurfaces, 4.5);
228
313
  if (main !== current.main) {
229
314
  derivedStatusEntries[status] = {
230
315
  ...current,
@@ -238,19 +323,57 @@ export function createAppTheme(appConfig = {}) {
238
323
  if (appConfig.palette?.controlBorder?.[state] !== undefined) continue;
239
324
 
240
325
  const current = finalPalette.controlBorder[state];
241
- const derived = state === 'error'
242
- ? deriveDarkenedPageColour(current, contrastSurfaces, 3)
243
- : deriveAlphaPageColour(current, contrastSurfaces, 3);
326
+ const derived = isDark
327
+ ? state === 'error'
328
+ ? deriveLightenedPageColour(current, [finalPalette.background.default, finalPalette.error.bg], 3)
329
+ : deriveDarkAlphaColour(finalPalette.ink.primary, contrastSurfaces)
330
+ : state === 'error'
331
+ ? deriveDarkenedPageColour(current, contrastSurfaces, 3)
332
+ : deriveAlphaPageColour(current, contrastSurfaces, 3);
244
333
  if (derived !== current) derivedControlBorderEntries[state] = derived;
245
334
  }
335
+ if (isDark && appConfig.palette?.controlBorder?.hover === undefined) {
336
+ const main = derivedControlBorderEntries.main || finalPalette.controlBorder.main;
337
+ const alpha = Number(main.match(/,([\d.]+)\)$/)?.[1] || 0);
338
+ const hoverAlpha = Math.min(1, Number((alpha + 0.15).toFixed(2)));
339
+ const [red, green, blue] = inkChannels(finalPalette.ink.primary);
340
+ derivedControlBorderEntries.hover = `rgba(${red},${green},${blue},${hoverAlpha.toFixed(2)})`;
341
+ }
342
+
343
+ const primaryMain = isDark
344
+ ? deriveLightenedPageColour(finalPalette.primary.main, [finalPalette.background.default], 4.5)
345
+ : finalPalette.primary.main;
346
+ const primaryContrastText = isDark
347
+ ? (getContrastRatio('#FFFFFF', primaryMain) >= getContrastRatio(finalPalette.background.default, primaryMain)
348
+ ? '#FFFFFF'
349
+ : finalPalette.background.default)
350
+ : finalPalette.primary.contrastText;
351
+ const derivedSeries = isDark && appConfig.palette?.dataSeries?.categorical === undefined
352
+ ? finalPalette.dataSeries.categorical.map((colour) => (
353
+ deriveLightenedPageColour(colour, [finalPalette.background.paper], 3)
354
+ ))
355
+ : finalPalette.dataSeries.categorical;
246
356
 
247
357
  const computedPalette = {
248
358
  ...derivedStatusEntries,
249
359
  controlBorder: {
250
360
  ...finalPalette.controlBorder,
251
361
  ...derivedControlBorderEntries,
252
- focus: deriveFocusColour(finalPalette.primary.main, contrastSurfaces),
362
+ focus: isDark
363
+ ? (appConfig.palette?.controlBorder?.focus
364
+ ?? deriveLightenedColour(primaryMain, contrastSurfaces))
365
+ : deriveFocusColour(finalPalette.primary.main, contrastSurfaces),
366
+ },
367
+ primary: {
368
+ ...finalPalette.primary,
369
+ // A lightened main needs its own light/dark shades, or hover states keep
370
+ // the shades of the colour that was replaced.
371
+ ...(isDark && primaryMain !== finalPalette.primary.main
372
+ ? withMainShades(primaryMain, primaryContrastText)
373
+ : { main: primaryMain }),
374
+ contrastText: primaryContrastText,
253
375
  },
376
+ dataSeries: { categorical: derivedSeries },
254
377
  };
255
378
  const resolvedPalette = { ...finalPalette, ...computedPalette };
256
379
  const fontFamily = appConfig.typography?.fontFamily || BASELINE_STATIC.typography.fontFamily;
@@ -266,20 +389,24 @@ export function createAppTheme(appConfig = {}) {
266
389
  ...BASELINE_STATIC.fontLoading,
267
390
  weights: [...BASELINE_STATIC.fontLoading.weights],
268
391
  },
392
+ shadow: isDark ? { ...BASELINE_STATIC.shadow, overlay: DARK_OVERLAY_SHADOW } : BASELINE_STATIC.shadow,
269
393
  palette: resolvedPalette,
270
394
  themeCompleteness: {
271
395
  baseline: true,
272
- exemptions: BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS.map((exemption) => ({
396
+ exemptions: (isDark
397
+ ? BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK
398
+ : BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS).map((exemption) => ({
273
399
  ...exemption,
274
400
  })),
275
401
  },
276
402
  },
277
403
  {
278
- components: createPaletteAwareComponents(resolvedPalette),
404
+ components: createPaletteAwareComponents(resolvedPalette, isDark),
279
405
  },
280
406
  appConfig,
281
407
  {
282
408
  palette: {
409
+ ...(isDark ? computedPalette : {}),
283
410
  dataSeries: {
284
411
  categorical: [...resolvedPalette.dataSeries.categorical],
285
412
  },
@@ -294,10 +294,16 @@ function contrastFindings(theme) {
294
294
  }
295
295
  }
296
296
 
297
- const backgrounds = {
298
- white: '#FFFFFF',
299
- page: theme.palette?.background?.default,
300
- };
297
+ const dark = theme.palette?.mode === 'dark';
298
+ const backgrounds = dark
299
+ ? {
300
+ paper: theme.palette?.background?.paper,
301
+ page: theme.palette?.background?.default,
302
+ }
303
+ : {
304
+ white: '#FFFFFF',
305
+ page: theme.palette?.background?.default,
306
+ };
301
307
  for (const status of STATUS_KEYS) {
302
308
  const main = theme.palette?.[status]?.main;
303
309
  if (!main) continue;
@@ -113,6 +113,73 @@ export const BASELINE_PALETTE = {
113
113
  },
114
114
  };
115
115
 
116
+ export const DARK_OVERLAY_SURFACE = '#232A31';
117
+ export const DARK_OVERLAY_SHADOW = '0 8px 28px rgba(0,0,0,.45)';
118
+ export const DARK_SCRIM = 'rgba(0,0,0,.55)';
119
+
120
+ export const BASELINE_PALETTE_DARK = {
121
+ mode: 'dark',
122
+ ink: {
123
+ primary: '#E6EBEF',
124
+ secondary: '#AAB5BF',
125
+ muted: '#87919A',
126
+ },
127
+ text: {
128
+ primary: '#E6EBEF',
129
+ secondary: '#AAB5BF',
130
+ disabled: '#87919A',
131
+ },
132
+ background: {
133
+ default: '#14181B',
134
+ paper: '#1B2126',
135
+ subtle: '#20262C',
136
+ },
137
+ divider: '#2B333A',
138
+ controlBorder: {
139
+ // These are replaced by the dark alpha derivation in createAppTheme.
140
+ main: 'rgba(230,235,239,0)',
141
+ hover: 'rgba(230,235,239,0)',
142
+ error: '#E58B80',
143
+ },
144
+ success: {
145
+ ...withMainShades('#7BC496', '#14181B'),
146
+ text: '#7BC496',
147
+ fill: '#7BC496',
148
+ fillText: '#14181B',
149
+ bg: '#1C2A22',
150
+ },
151
+ warning: {
152
+ ...withMainShades('#D8BD7E', '#14181B'),
153
+ text: '#D8BD7E',
154
+ fill: '#D8BD7E',
155
+ fillText: '#14181B',
156
+ bg: '#2A2418',
157
+ },
158
+ error: {
159
+ ...withMainShades('#E58B80', '#14181B'),
160
+ text: '#E58B80',
161
+ fill: '#E58B80',
162
+ fillText: '#14181B',
163
+ bg: '#3A2320',
164
+ },
165
+ info: {
166
+ ...withMainShades('#8FB8E0', '#14181B'),
167
+ text: '#8FB8E0',
168
+ fill: '#8FB8E0',
169
+ fillText: '#14181B',
170
+ bg: '#1A2633',
171
+ },
172
+ stale: {
173
+ text: '#AAB5BF',
174
+ fill: '#AAB5BF',
175
+ fillText: '#14181B',
176
+ bg: '#20262C',
177
+ },
178
+ dataSeries: {
179
+ categorical: SERIES_COLOURS,
180
+ },
181
+ };
182
+
116
183
  export const OVERLAY_SHADOW =
117
184
  '0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)';
118
185
 
@@ -399,3 +466,8 @@ export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS = [
399
466
  reason: `The canonical ${variant} weight deliberately equals MUI's numeric default.`,
400
467
  })),
401
468
  ];
469
+
470
+ export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK =
471
+ BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS.filter(
472
+ ({ surface }) => surface !== 'palette.background.paper',
473
+ );
@@ -0,0 +1,127 @@
1
+ import { darken, lighten } from '@mui/material/styles';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import {
5
+ assertThemeComplete,
6
+ calculateContrastRatio,
7
+ createAppTheme,
8
+ } from '../src/theme';
9
+ import {
10
+ BASELINE_PALETTE_DARK,
11
+ SERIES_COLOURS,
12
+ DARK_OVERLAY_SHADOW,
13
+ DARK_OVERLAY_SURFACE,
14
+ DARK_SCRIM,
15
+ } from '../src/theme/tokens';
16
+
17
+ const dark = (primary = '#468AB2', extra = {}) => createAppTheme({
18
+ palette: { mode: 'dark', primary: { main: primary }, ...extra },
19
+ });
20
+
21
+ describe('createAppTheme dark baseline', () => {
22
+ it('is complete, including a primary that must be lightened', () => {
23
+ expect(assertThemeComplete(dark()).findings).toEqual([]);
24
+ expect(assertThemeComplete(dark('#2F4F96')).findings).toEqual([]);
25
+ });
26
+
27
+ it('derives focus contrast against page and paper in both modes', () => {
28
+ for (const theme of [
29
+ createAppTheme({ palette: { primary: { main: '#0F62FE' } } }),
30
+ dark(),
31
+ ]) {
32
+ expect(calculateContrastRatio(theme.palette.controlBorder.focus, theme.palette.background.default)).toBeGreaterThanOrEqual(3);
33
+ expect(calculateContrastRatio(theme.palette.controlBorder.focus, theme.palette.background.paper)).toBeGreaterThanOrEqual(3);
34
+ }
35
+ });
36
+
37
+ it('derives dark control borders and focus from the rules', () => {
38
+ const theme = dark();
39
+ expect(theme.palette.controlBorder.main).toBe('rgba(230,235,239,0.38)');
40
+ expect(theme.palette.controlBorder.hover).toBe('rgba(230,235,239,0.53)');
41
+ expect(theme.palette.controlBorder.error).toBe('#E58B80');
42
+ expect(theme.palette.controlBorder.focus).toBe('#468AB2');
43
+ expect(dark('#2F4F96').palette.controlBorder.focus).toBe('#6D84B6');
44
+ const rgbInk = dark('#468AB2', { ink: { primary: 'rgb(230, 235, 239)' } });
45
+ expect(rgbInk.palette.controlBorder.main).toBe('rgba(230,235,239,0.38)');
46
+ const ownFocus = dark('#468AB2', { controlBorder: { focus: '#FFFFFF' } });
47
+ expect(ownFocus.palette.controlBorder.focus).toBe('#FFFFFF');
48
+ });
49
+
50
+ it('recomputes primary shades for a lightened primary', () => {
51
+ const theme = dark('#2F4F96');
52
+ expect(theme.palette.primary.light).toBe(lighten('#6D84B6', 0.2));
53
+ expect(theme.palette.primary.dark).toBe(darken('#6D84B6', 0.3));
54
+ });
55
+
56
+ it('uses the complete dark status family', () => {
57
+ for (const status of ['success', 'warning', 'error', 'info']) {
58
+ expect(dark().palette[status]).toMatchObject(BASELINE_PALETTE_DARK[status]);
59
+ }
60
+ expect(dark().palette.stale).toMatchObject(BASELINE_PALETTE_DARK.stale);
61
+ });
62
+
63
+ it.each([
64
+ ['#468AB2', '#468AB2'],
65
+ ['#2F4F96', '#6D84B6'],
66
+ ['#432CA1', '#8576C2'],
67
+ ['#D49040', '#D49040'],
68
+ ])('applies the dark primary rule to %s', (input, expected) => {
69
+ const theme = dark(input);
70
+ expect(theme.palette.primary.main).toBe(expected);
71
+ expect(theme.palette.primary.contrastText).toBe('#14181B');
72
+ });
73
+
74
+ it('keeps baseline statuses on webshop-guenter page and protects failing baseline values', () => {
75
+ const webshop = dark('#D49040', { background: { default: '#0E0B08', paper: '#171209' } });
76
+ for (const status of ['success', 'warning', 'error', 'info']) {
77
+ expect(webshop.palette[status].main).toBe(BASELINE_PALETTE_DARK[status].main);
78
+ }
79
+ const failing = dark('#468AB2', { background: { default: '#707070', paper: '#787878' } });
80
+ expect(failing.palette.success.main).not.toBe(BASELINE_PALETTE_DARK.success.main);
81
+ expect(calculateContrastRatio(failing.palette.success.main, failing.palette.background.default)).toBeGreaterThanOrEqual(4.5);
82
+ for (const status of ['success', 'warning', 'error', 'info']) {
83
+ const net = dark('#468AB2', { background: { default: '#707070', paper: '#787878' } }).palette[status];
84
+ expect(calculateContrastRatio(net.main, '#707070')).toBeGreaterThanOrEqual(4.5);
85
+ expect(calculateContrastRatio(net.main, net.bg)).toBeGreaterThanOrEqual(4.5);
86
+ }
87
+ const supplied = dark('#468AB2', {
88
+ background: { default: '#707070' },
89
+ success: { main: '#123456' },
90
+ });
91
+ expect(supplied.palette.success.main).toBe('#123456');
92
+ });
93
+
94
+ it('uses the dark overlay surface, shadow, scrim, and shadow token', () => {
95
+ const theme = dark();
96
+ for (const component of ['MuiDialog', 'MuiMenu', 'MuiPopover', 'MuiDrawer']) {
97
+ expect(theme.components[component].styleOverrides.paper).toMatchObject({
98
+ backgroundColor: DARK_OVERLAY_SURFACE,
99
+ boxShadow: DARK_OVERLAY_SHADOW,
100
+ });
101
+ }
102
+ expect(theme.components.MuiBackdrop.styleOverrides.root.backgroundColor).toBe(DARK_SCRIM);
103
+ expect(theme.shadow.overlay).toBe(DARK_OVERLAY_SHADOW);
104
+ });
105
+
106
+ it('lightens baseline series for baseline paper and adapts to app paper', () => {
107
+ const baseline = dark().palette.dataSeries.categorical;
108
+ expect(baseline).toEqual(['#506BA3', '#3E80B8', '#2E8F8A', '#7A5FA8', '#A1588C', '#8A7355']);
109
+ const adapted = dark('#D49040', { background: { paper: '#171209' } }).palette.dataSeries.categorical;
110
+ expect(adapted).toHaveLength(SERIES_COLOURS.length);
111
+ adapted.forEach((colour) => {
112
+ expect(calculateContrastRatio(colour, '#171209')).toBeGreaterThanOrEqual(3);
113
+ });
114
+ });
115
+
116
+ it('leaves app-supplied series untouched', () => {
117
+ const series = ['#111111', '#222222', '#333333'];
118
+ expect(dark('#468AB2', { dataSeries: { categorical: series } }).palette.dataSeries.categorical).toEqual(series);
119
+ });
120
+
121
+ it('resolves dark autofill from paper and primary ink', () => {
122
+ const input = dark().components.MuiOutlinedInput.styleOverrides.input['&:-webkit-autofill'];
123
+ expect(input.WebkitBoxShadow).toBe('0 0 0 100px #1B2126 inset');
124
+ expect(input.WebkitTextFillColor).toBe('#E6EBEF');
125
+ expect(input.caretColor).toBe('#E6EBEF');
126
+ });
127
+ });
@@ -0,0 +1,40 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createAppTheme } from '../src/theme';
5
+
6
+ const sortValue = (value) => {
7
+ if (typeof value === 'function') return String(value);
8
+ if (Array.isArray(value)) return value.map(sortValue);
9
+ if (value && typeof value === 'object') {
10
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
11
+ }
12
+ return value;
13
+ };
14
+
15
+ const fixture = JSON.parse(readFileSync(new URL('./fixtures/light-theme-baseline.json', import.meta.url)));
16
+
17
+ function snapshot(baselinePalette = {}) {
18
+ return sortValue({
19
+ baseline: createAppTheme({ palette: { primary: { main: '#0F62FE' }, ...baselinePalette } }),
20
+ override: createAppTheme({
21
+ palette: {
22
+ primary: { main: '#0F62FE' },
23
+ background: { default: '#F0F2F4' },
24
+ success: { main: '#126B36' },
25
+ },
26
+ typography: { fontFamily: 'Fixture Sans' },
27
+ }),
28
+ });
29
+ }
30
+
31
+ describe('createAppTheme light regression guard', () => {
32
+ it('matches the pre-change serialized light themes exactly', () => {
33
+ expect(snapshot()).toEqual(fixture);
34
+ });
35
+
36
+ it('detects a changed light value', () => {
37
+ // Produced by the real factory: one different light value must differ from the fixture.
38
+ expect(snapshot({ background: { default: '#FAFBFC' } })).not.toEqual(fixture);
39
+ });
40
+ });