@dryui/theme-wizard 3.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/dist/actions.d.ts +0 -4
- package/dist/actions.js +0 -9
- package/dist/components/AlphaSlider.svelte +0 -13
- package/dist/components/AlphaSlider.svelte.d.ts +0 -9
- package/dist/components/ContrastBadge.svelte +0 -22
- package/dist/components/ContrastBadge.svelte.d.ts +0 -8
- package/dist/components/HsbPicker.svelte +0 -304
- package/dist/components/HsbPicker.svelte.d.ts +0 -9
- package/dist/components/StepIndicator.svelte +0 -87
- package/dist/components/StepIndicator.svelte.d.ts +0 -7
- package/dist/components/TokenPreview.svelte +0 -55
- package/dist/components/TokenPreview.svelte.d.ts +0 -8
- package/dist/components/WizardShell.svelte +0 -140
- package/dist/components/WizardShell.svelte.d.ts +0 -15
- package/dist/engine/derivation.d.ts +0 -282
- package/dist/engine/derivation.js +0 -1445
- package/dist/engine/derivation.test.d.ts +0 -1
- package/dist/engine/derivation.test.js +0 -956
- package/dist/engine/export-css.d.ts +0 -32
- package/dist/engine/export-css.js +0 -90
- package/dist/engine/export-css.test.d.ts +0 -1
- package/dist/engine/export-css.test.js +0 -78
- package/dist/engine/index.d.ts +0 -10
- package/dist/engine/index.js +0 -6
- package/dist/engine/palette.d.ts +0 -16
- package/dist/engine/palette.js +0 -44
- package/dist/engine/presets.d.ts +0 -6
- package/dist/engine/presets.js +0 -34
- package/dist/engine/url-codec.d.ts +0 -53
- package/dist/engine/url-codec.js +0 -243
- package/dist/engine/url-codec.test.d.ts +0 -1
- package/dist/engine/url-codec.test.js +0 -137
- package/dist/index.d.ts +0 -14
- package/dist/index.js +0 -17
- package/dist/state.svelte.d.ts +0 -104
- package/dist/state.svelte.js +0 -574
- package/dist/steps/BrandColor.svelte +0 -218
- package/dist/steps/BrandColor.svelte.d.ts +0 -6
- package/dist/steps/Personality.svelte +0 -319
- package/dist/steps/Personality.svelte.d.ts +0 -3
- package/dist/steps/PreviewExport.svelte +0 -115
- package/dist/steps/PreviewExport.svelte.d.ts +0 -9
- package/dist/steps/Shape.svelte +0 -121
- package/dist/steps/Shape.svelte.d.ts +0 -18
- package/dist/steps/Typography.svelte +0 -115
- package/dist/steps/Typography.svelte.d.ts +0 -18
|
@@ -1,956 +0,0 @@
|
|
|
1
|
-
// apps/docs/src/lib/theme-wizard/derivation.test.ts
|
|
2
|
-
import { describe, test, expect } from 'bun:test';
|
|
3
|
-
import { hsbToHsl, hslToHsb, hslToRgb, hslToHex, hexToHsl, cssColorToRgb, relativeLuminance, contrastRatio, contrastBetweenCssColors, apcaContrast, apcaContrastBetweenCssColors, apcaSrgbToY, meetsApca, meetsContrast, compareForegroundAcrossSurfaces, measureForegroundOnSurface, luminanceFromHsl, generateTheme, generateThemeModel } from './derivation';
|
|
4
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
5
|
-
/** Round a number to N decimal places. */
|
|
6
|
-
function round(n, places = 3) {
|
|
7
|
-
return Math.round(n * 10 ** places) / 10 ** places;
|
|
8
|
-
}
|
|
9
|
-
// ─── HSB ↔ HSL Conversions ────────────────────────────────────────────────────
|
|
10
|
-
describe('hsbToHsl', () => {
|
|
11
|
-
test('converts a typical saturated color', () => {
|
|
12
|
-
// HSB(120, 1.0, 1.0) = fully saturated green = HSL(120, 1.0, 0.5)
|
|
13
|
-
const result = hsbToHsl(120, 1.0, 1.0);
|
|
14
|
-
expect(round(result.h)).toBe(120);
|
|
15
|
-
expect(round(result.s)).toBe(1.0);
|
|
16
|
-
expect(round(result.l)).toBe(0.5);
|
|
17
|
-
});
|
|
18
|
-
test('converts pure white (s=0, b=1)', () => {
|
|
19
|
-
const result = hsbToHsl(0, 0, 1);
|
|
20
|
-
expect(result.l).toBe(1);
|
|
21
|
-
expect(result.s).toBe(0);
|
|
22
|
-
});
|
|
23
|
-
test('converts pure black (s=0, b=0)', () => {
|
|
24
|
-
const result = hsbToHsl(0, 0, 0);
|
|
25
|
-
expect(result.l).toBe(0);
|
|
26
|
-
expect(result.s).toBe(0);
|
|
27
|
-
});
|
|
28
|
-
test('converts mid-brightness desaturated (gray)', () => {
|
|
29
|
-
// HSB(0, 0, 0.5) = 50% gray = HSL(0, 0, 0.5)
|
|
30
|
-
const result = hsbToHsl(0, 0, 0.5);
|
|
31
|
-
expect(round(result.l)).toBe(0.5);
|
|
32
|
-
expect(round(result.s)).toBe(0);
|
|
33
|
-
});
|
|
34
|
-
test('converts a realistic brand color HSB(220, 0.8, 0.7)', () => {
|
|
35
|
-
const result = hsbToHsl(220, 0.8, 0.7);
|
|
36
|
-
expect(result.h).toBe(220);
|
|
37
|
-
// b=0.7, s=0.8: l = 0.7*(1-0.4)=0.42
|
|
38
|
-
expect(round(result.l)).toBe(0.42);
|
|
39
|
-
// s_hsl = (0.7 - 0.42) / min(0.42, 0.58) = 0.28/0.42 ≈ 0.667
|
|
40
|
-
expect(round(result.s)).toBeCloseTo(0.667, 2);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
describe('hslToHsb', () => {
|
|
44
|
-
test('converts a typical saturated color back', () => {
|
|
45
|
-
// HSL(120, 1.0, 0.5) → HSB(120, 1.0, 1.0)
|
|
46
|
-
const result = hslToHsb(120, 1.0, 0.5);
|
|
47
|
-
expect(round(result.h)).toBe(120);
|
|
48
|
-
expect(round(result.s)).toBe(1.0);
|
|
49
|
-
expect(round(result.b)).toBe(1.0);
|
|
50
|
-
});
|
|
51
|
-
test('converts pure white', () => {
|
|
52
|
-
const result = hslToHsb(0, 0, 1);
|
|
53
|
-
expect(result.b).toBe(1);
|
|
54
|
-
expect(result.s).toBe(0);
|
|
55
|
-
});
|
|
56
|
-
test('converts pure black', () => {
|
|
57
|
-
const result = hslToHsb(0, 0, 0);
|
|
58
|
-
expect(result.b).toBe(0);
|
|
59
|
-
expect(result.s).toBe(0);
|
|
60
|
-
});
|
|
61
|
-
test('round-trip: HSB → HSL → HSB', () => {
|
|
62
|
-
const cases = [
|
|
63
|
-
[220, 0.8, 0.7],
|
|
64
|
-
[0, 0.5, 0.5],
|
|
65
|
-
[300, 1.0, 0.6],
|
|
66
|
-
[60, 0.3, 0.9]
|
|
67
|
-
];
|
|
68
|
-
for (const [h, s, b] of cases) {
|
|
69
|
-
const hsl = hsbToHsl(h, s, b);
|
|
70
|
-
const back = hslToHsb(hsl.h, hsl.s, hsl.l);
|
|
71
|
-
expect(round(back.h)).toBeCloseTo(h, 1);
|
|
72
|
-
expect(round(back.s)).toBeCloseTo(s, 2);
|
|
73
|
-
expect(round(back.b)).toBeCloseTo(b, 2);
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
test('round-trip: HSL → HSB → HSL', () => {
|
|
77
|
-
const cases = [
|
|
78
|
-
[120, 0.5, 0.5],
|
|
79
|
-
[240, 1.0, 0.25],
|
|
80
|
-
[0, 0, 0.75],
|
|
81
|
-
[180, 0.6, 0.4]
|
|
82
|
-
];
|
|
83
|
-
for (const [h, s, l] of cases) {
|
|
84
|
-
const hsb = hslToHsb(h, s, l);
|
|
85
|
-
const back = hsbToHsl(hsb.h, hsb.s, hsb.b);
|
|
86
|
-
expect(round(back.h)).toBeCloseTo(h, 1);
|
|
87
|
-
expect(round(back.s)).toBeCloseTo(s, 2);
|
|
88
|
-
expect(round(back.l)).toBeCloseTo(l, 2);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
// ─── hslToRgb ─────────────────────────────────────────────────────────────────
|
|
93
|
-
describe('hslToRgb', () => {
|
|
94
|
-
test('pure red (0, 1, 0.5)', () => {
|
|
95
|
-
const [r, g, b] = hslToRgb(0, 1, 0.5);
|
|
96
|
-
expect(r).toBe(255);
|
|
97
|
-
expect(g).toBe(0);
|
|
98
|
-
expect(b).toBe(0);
|
|
99
|
-
});
|
|
100
|
-
test('pure green (120, 1, 0.5)', () => {
|
|
101
|
-
const [r, g, b] = hslToRgb(120, 1, 0.5);
|
|
102
|
-
expect(r).toBe(0);
|
|
103
|
-
expect(g).toBe(255);
|
|
104
|
-
expect(b).toBe(0);
|
|
105
|
-
});
|
|
106
|
-
test('pure blue (240, 1, 0.5)', () => {
|
|
107
|
-
const [r, g, b] = hslToRgb(240, 1, 0.5);
|
|
108
|
-
expect(r).toBe(0);
|
|
109
|
-
expect(g).toBe(0);
|
|
110
|
-
expect(b).toBe(255);
|
|
111
|
-
});
|
|
112
|
-
test('white (0, 0, 1)', () => {
|
|
113
|
-
const [r, g, b] = hslToRgb(0, 0, 1);
|
|
114
|
-
expect(r).toBe(255);
|
|
115
|
-
expect(g).toBe(255);
|
|
116
|
-
expect(b).toBe(255);
|
|
117
|
-
});
|
|
118
|
-
test('black (0, 0, 0)', () => {
|
|
119
|
-
const [r, g, b] = hslToRgb(0, 0, 0);
|
|
120
|
-
expect(r).toBe(0);
|
|
121
|
-
expect(g).toBe(0);
|
|
122
|
-
expect(b).toBe(0);
|
|
123
|
-
});
|
|
124
|
-
test('gray (0, 0, 0.5)', () => {
|
|
125
|
-
const [r, g, b] = hslToRgb(0, 0, 0.5);
|
|
126
|
-
expect(r).toBe(g);
|
|
127
|
-
expect(g).toBe(b);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
// ─── Hex Conversions ──────────────────────────────────────────────────────────
|
|
131
|
-
describe('hslToHex', () => {
|
|
132
|
-
test('pure red', () => {
|
|
133
|
-
expect(hslToHex(0, 1, 0.5)).toBe('#ff0000');
|
|
134
|
-
});
|
|
135
|
-
test('pure green', () => {
|
|
136
|
-
expect(hslToHex(120, 1, 0.5)).toBe('#00ff00');
|
|
137
|
-
});
|
|
138
|
-
test('pure blue', () => {
|
|
139
|
-
expect(hslToHex(240, 1, 0.5)).toBe('#0000ff');
|
|
140
|
-
});
|
|
141
|
-
test('white', () => {
|
|
142
|
-
expect(hslToHex(0, 0, 1)).toBe('#ffffff');
|
|
143
|
-
});
|
|
144
|
-
test('black', () => {
|
|
145
|
-
expect(hslToHex(0, 0, 0)).toBe('#000000');
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
describe('hexToHsl', () => {
|
|
149
|
-
test('pure red #ff0000', () => {
|
|
150
|
-
const result = hexToHsl('#ff0000');
|
|
151
|
-
expect(round(result.h)).toBe(0);
|
|
152
|
-
expect(round(result.s)).toBe(1);
|
|
153
|
-
expect(round(result.l)).toBe(0.5);
|
|
154
|
-
});
|
|
155
|
-
test('pure white #ffffff', () => {
|
|
156
|
-
const result = hexToHsl('#ffffff');
|
|
157
|
-
expect(result.l).toBe(1);
|
|
158
|
-
expect(result.s).toBe(0);
|
|
159
|
-
});
|
|
160
|
-
test('pure black #000000', () => {
|
|
161
|
-
const result = hexToHsl('#000000');
|
|
162
|
-
expect(result.l).toBe(0);
|
|
163
|
-
expect(result.s).toBe(0);
|
|
164
|
-
});
|
|
165
|
-
test('throws on invalid hex', () => {
|
|
166
|
-
expect(() => hexToHsl('#gg0000')).toThrow();
|
|
167
|
-
expect(() => hexToHsl('ff0000')).toThrow();
|
|
168
|
-
expect(() => hexToHsl('#ff00')).toThrow();
|
|
169
|
-
});
|
|
170
|
-
test('round-trip: hex → HSL → hex', () => {
|
|
171
|
-
const hexes = ['#ff5733', '#1a2b3c', '#aabbcc', '#123456'];
|
|
172
|
-
for (const hex of hexes) {
|
|
173
|
-
const hsl = hexToHsl(hex);
|
|
174
|
-
const back = hslToHex(hsl.h, hsl.s, hsl.l);
|
|
175
|
-
expect(back).toBe(hex);
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
test('case-insensitive input', () => {
|
|
179
|
-
const lower = hexToHsl('#ff5733');
|
|
180
|
-
const upper = hexToHsl('#FF5733');
|
|
181
|
-
expect(round(lower.h)).toBe(round(upper.h));
|
|
182
|
-
expect(round(lower.s)).toBe(round(upper.s));
|
|
183
|
-
expect(round(lower.l)).toBe(round(upper.l));
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
describe('cssColorToRgb', () => {
|
|
187
|
-
test('parses hex colours', () => {
|
|
188
|
-
expect(cssColorToRgb('#ffffff')).toEqual([255, 255, 255]);
|
|
189
|
-
});
|
|
190
|
-
test('parses hsl and hsla colours', () => {
|
|
191
|
-
expect(cssColorToRgb('hsl(0, 0%, 0%)')).toEqual([0, 0, 0]);
|
|
192
|
-
expect(cssColorToRgb('hsla(0, 0%, 100%, 0.5)')).toEqual([255, 255, 255]);
|
|
193
|
-
});
|
|
194
|
-
test('returns null for unsupported strings', () => {
|
|
195
|
-
expect(cssColorToRgb('rgb(0, 0, 0)')).toBeNull();
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
// ─── WCAG Luminance & Contrast ────────────────────────────────────────────────
|
|
199
|
-
describe('relativeLuminance', () => {
|
|
200
|
-
test('white has luminance 1', () => {
|
|
201
|
-
expect(round(relativeLuminance(255, 255, 255))).toBe(1);
|
|
202
|
-
});
|
|
203
|
-
test('black has luminance 0', () => {
|
|
204
|
-
expect(round(relativeLuminance(0, 0, 0))).toBe(0);
|
|
205
|
-
});
|
|
206
|
-
test('luminance is in [0, 1]', () => {
|
|
207
|
-
const lum = relativeLuminance(128, 0, 255);
|
|
208
|
-
expect(lum).toBeGreaterThanOrEqual(0);
|
|
209
|
-
expect(lum).toBeLessThanOrEqual(1);
|
|
210
|
-
});
|
|
211
|
-
test('red has correct relative luminance (~0.2126)', () => {
|
|
212
|
-
// Pure red: 0.2126
|
|
213
|
-
const lum = relativeLuminance(255, 0, 0);
|
|
214
|
-
expect(lum).toBeCloseTo(0.2126, 3);
|
|
215
|
-
});
|
|
216
|
-
test('green has correct relative luminance (~0.7152)', () => {
|
|
217
|
-
const lum = relativeLuminance(0, 255, 0);
|
|
218
|
-
expect(lum).toBeCloseTo(0.7152, 3);
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
describe('contrastRatio', () => {
|
|
222
|
-
test('black vs white = 21', () => {
|
|
223
|
-
expect(round(contrastRatio(0, 1))).toBe(21);
|
|
224
|
-
});
|
|
225
|
-
test('same color = 1', () => {
|
|
226
|
-
expect(round(contrastRatio(0.5, 0.5))).toBe(1);
|
|
227
|
-
});
|
|
228
|
-
test('is symmetric', () => {
|
|
229
|
-
const a = contrastRatio(0.2, 0.7);
|
|
230
|
-
const b = contrastRatio(0.7, 0.2);
|
|
231
|
-
expect(round(a)).toBe(round(b));
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
describe('contrastBetweenCssColors', () => {
|
|
235
|
-
test('supports mixed hex and hsl inputs', () => {
|
|
236
|
-
expect(round(contrastBetweenCssColors('#ffffff', 'hsl(0, 0%, 0%)') ?? 0)).toBe(21);
|
|
237
|
-
});
|
|
238
|
-
test('composites transparent foreground colours against the background', () => {
|
|
239
|
-
const opaque = contrastBetweenCssColors('hsl(230, 65%, 57%)', '#ffffff');
|
|
240
|
-
const transparent = contrastBetweenCssColors('hsla(230, 65%, 57%, 0.1)', '#ffffff');
|
|
241
|
-
expect(opaque).not.toBeNull();
|
|
242
|
-
expect(transparent).not.toBeNull();
|
|
243
|
-
expect(transparent ?? 0).toBeLessThan(opaque ?? 0);
|
|
244
|
-
});
|
|
245
|
-
test('returns null when a colour cannot be parsed', () => {
|
|
246
|
-
expect(contrastBetweenCssColors('rgb(0, 0, 0)', '#ffffff')).toBeNull();
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
describe('APCA contrast', () => {
|
|
250
|
-
test('matches the published #888 on #fff keystone value', () => {
|
|
251
|
-
const contrast = apcaContrastBetweenCssColors('#888888', '#ffffff');
|
|
252
|
-
expect(contrast).not.toBeNull();
|
|
253
|
-
expect(contrast ?? 0).toBeCloseTo(63.056, 2);
|
|
254
|
-
});
|
|
255
|
-
test('matches the published #fff on #888 keystone value', () => {
|
|
256
|
-
const contrast = apcaContrastBetweenCssColors('#ffffff', '#888888');
|
|
257
|
-
expect(contrast).not.toBeNull();
|
|
258
|
-
expect(contrast ?? 0).toBeCloseTo(-68.541, 2);
|
|
259
|
-
});
|
|
260
|
-
test('calculates signed polarity-sensitive Lc values', () => {
|
|
261
|
-
const darkOnLight = apcaContrast(apcaSrgbToY([0, 0, 0]), apcaSrgbToY([255, 255, 255]));
|
|
262
|
-
const lightOnDark = apcaContrast(apcaSrgbToY([255, 255, 255]), apcaSrgbToY([0, 0, 0]));
|
|
263
|
-
expect(darkOnLight).toBeGreaterThan(0);
|
|
264
|
-
expect(lightOnDark).toBeLessThan(0);
|
|
265
|
-
});
|
|
266
|
-
test('returns null when a colour cannot be parsed', () => {
|
|
267
|
-
expect(apcaContrastBetweenCssColors('rgb(0, 0, 0)', '#ffffff')).toBeNull();
|
|
268
|
-
});
|
|
269
|
-
test('supports absolute-value threshold checks', () => {
|
|
270
|
-
expect(meetsApca(-68.541, 60)).toBe(true);
|
|
271
|
-
expect(meetsApca(42, 60)).toBe(false);
|
|
272
|
-
});
|
|
273
|
-
test('composites transparent foreground colours before APCA measurement', () => {
|
|
274
|
-
const opaque = apcaContrastBetweenCssColors('hsl(230, 65%, 57%)', '#ffffff');
|
|
275
|
-
const transparent = apcaContrastBetweenCssColors('hsla(230, 65%, 57%, 0.1)', '#ffffff');
|
|
276
|
-
expect(opaque).not.toBeNull();
|
|
277
|
-
expect(transparent).not.toBeNull();
|
|
278
|
-
expect(Math.abs(transparent ?? 0)).toBeLessThan(Math.abs(opaque ?? 0));
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
describe('meetsContrast', () => {
|
|
282
|
-
test('black/white meets 21:1', () => {
|
|
283
|
-
expect(meetsContrast(0, 1, 21)).toBe(true);
|
|
284
|
-
});
|
|
285
|
-
test('black/white meets 4.5:1', () => {
|
|
286
|
-
expect(meetsContrast(0, 1, 4.5)).toBe(true);
|
|
287
|
-
});
|
|
288
|
-
test('same luminance does not meet 1.1:1', () => {
|
|
289
|
-
expect(meetsContrast(0.5, 0.5, 1.1)).toBe(false);
|
|
290
|
-
});
|
|
291
|
-
});
|
|
292
|
-
describe('luminanceFromHsl', () => {
|
|
293
|
-
test('white HSL(0,0,1) = 1', () => {
|
|
294
|
-
expect(round(luminanceFromHsl(0, 0, 1))).toBe(1);
|
|
295
|
-
});
|
|
296
|
-
test('black HSL(0,0,0) = 0', () => {
|
|
297
|
-
expect(round(luminanceFromHsl(0, 0, 0))).toBe(0);
|
|
298
|
-
});
|
|
299
|
-
test('returns same value as relativeLuminance for equivalent color', () => {
|
|
300
|
-
const [r, g, b] = hslToRgb(220, 0.7, 0.5);
|
|
301
|
-
const fromHsl = luminanceFromHsl(220, 0.7, 0.5);
|
|
302
|
-
const fromRgb = relativeLuminance(r, g, b);
|
|
303
|
-
expect(round(fromHsl)).toBe(round(fromRgb));
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
// ─── generateTheme ────────────────────────────────────────────────────────────
|
|
307
|
-
// All BrandInput values now use 0-100 scale for s and b.
|
|
308
|
-
describe('generateTheme — input normalization', () => {
|
|
309
|
-
test('accepts 0-100 scale for s and b', () => {
|
|
310
|
-
// HSB(220, 80, 70) in 0-100 scale should equal old (220, 0.8, 0.7) in 0-1 scale
|
|
311
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
312
|
-
expect(() => generateTheme(brand)).not.toThrow();
|
|
313
|
-
const { light } = generateTheme(brand);
|
|
314
|
-
expect(light['--dry-color-brand']).toBeDefined();
|
|
315
|
-
});
|
|
316
|
-
test('throws when h > 360', () => {
|
|
317
|
-
expect(() => generateTheme({ h: 361, s: 50, b: 50 })).toThrow(/brand\.h/);
|
|
318
|
-
});
|
|
319
|
-
test('throws when h < 0', () => {
|
|
320
|
-
expect(() => generateTheme({ h: -1, s: 50, b: 50 })).toThrow(/brand\.h/);
|
|
321
|
-
});
|
|
322
|
-
test('throws when s > 100', () => {
|
|
323
|
-
expect(() => generateTheme({ h: 180, s: 101, b: 50 })).toThrow(/brand\.s/);
|
|
324
|
-
});
|
|
325
|
-
test('throws when s < 0', () => {
|
|
326
|
-
expect(() => generateTheme({ h: 180, s: -1, b: 50 })).toThrow(/brand\.s/);
|
|
327
|
-
});
|
|
328
|
-
test('throws when b > 100', () => {
|
|
329
|
-
expect(() => generateTheme({ h: 180, s: 50, b: 101 })).toThrow(/brand\.b/);
|
|
330
|
-
});
|
|
331
|
-
test('throws when b < 0', () => {
|
|
332
|
-
expect(() => generateTheme({ h: 180, s: 50, b: -1 })).toThrow(/brand\.b/);
|
|
333
|
-
});
|
|
334
|
-
test('h=360 is accepted (boundary)', () => {
|
|
335
|
-
expect(() => generateTheme({ h: 360, s: 70, b: 60 })).not.toThrow();
|
|
336
|
-
});
|
|
337
|
-
test('s=0 and b=0 are accepted (boundary)', () => {
|
|
338
|
-
expect(() => generateTheme({ h: 0, s: 0, b: 0 })).not.toThrow();
|
|
339
|
-
});
|
|
340
|
-
test('s=100 and b=100 are accepted (boundary)', () => {
|
|
341
|
-
expect(() => generateTheme({ h: 0, s: 100, b: 100 })).not.toThrow();
|
|
342
|
-
});
|
|
343
|
-
});
|
|
344
|
-
describe('generateTheme — structure', () => {
|
|
345
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
346
|
-
const { light, dark } = generateTheme(brand);
|
|
347
|
-
const model = generateThemeModel(brand);
|
|
348
|
-
test('returns light and dark objects', () => {
|
|
349
|
-
expect(typeof light).toBe('object');
|
|
350
|
-
expect(typeof dark).toBe('object');
|
|
351
|
-
});
|
|
352
|
-
test('has an expanded semantic token surface', () => {
|
|
353
|
-
const totalLight = Object.keys(light).length;
|
|
354
|
-
const totalDark = Object.keys(dark).length;
|
|
355
|
-
expect(totalLight).toBeGreaterThanOrEqual(75);
|
|
356
|
-
expect(totalLight).toBeLessThanOrEqual(95);
|
|
357
|
-
expect(totalDark).toBeGreaterThanOrEqual(75);
|
|
358
|
-
expect(totalDark).toBeLessThanOrEqual(95);
|
|
359
|
-
// Both modes should have same token count
|
|
360
|
-
expect(totalLight).toBe(totalDark);
|
|
361
|
-
});
|
|
362
|
-
test('exposes first-class transparent primitive ladders', () => {
|
|
363
|
-
expect(model.transparentPrimitives.neutral.light.fill).toBe(light['--dry-color-fill']);
|
|
364
|
-
expect(model.transparentPrimitives.brand.dark.focusRing).toBe(model.tokens.dark['--dry-color-focus-ring']);
|
|
365
|
-
expect(model.transparentPrimitives.system.error.light.fill).toBe(light['--dry-color-fill-error']);
|
|
366
|
-
expect(model.transparentPrimitives.system.info.dark.on).toBe(dark['--dry-color-on-info']);
|
|
367
|
-
});
|
|
368
|
-
test('emits the expanded semantic parity tokens in both modes', () => {
|
|
369
|
-
const semanticParityKeys = [
|
|
370
|
-
'--dry-color-text-disabled',
|
|
371
|
-
'--dry-color-text-inverse',
|
|
372
|
-
'--dry-color-text-inverse-weak',
|
|
373
|
-
'--dry-color-text-inverse-disabled',
|
|
374
|
-
'--dry-color-icon-brand',
|
|
375
|
-
'--dry-color-icon-disabled',
|
|
376
|
-
'--dry-color-icon-inverse',
|
|
377
|
-
'--dry-color-icon-inverse-strong',
|
|
378
|
-
'--dry-color-stroke-focus',
|
|
379
|
-
'--dry-color-stroke-selected',
|
|
380
|
-
'--dry-color-stroke-disabled',
|
|
381
|
-
'--dry-color-stroke-inverse',
|
|
382
|
-
'--dry-color-stroke-inverse-weak',
|
|
383
|
-
'--dry-color-fill-strong',
|
|
384
|
-
'--dry-color-fill-weak',
|
|
385
|
-
'--dry-color-fill-weaker',
|
|
386
|
-
'--dry-color-fill-selected',
|
|
387
|
-
'--dry-color-fill-disabled',
|
|
388
|
-
'--dry-color-fill-overlay',
|
|
389
|
-
'--dry-color-fill-inverse',
|
|
390
|
-
'--dry-color-fill-inverse-weak',
|
|
391
|
-
'--dry-color-fill-inverse-hover',
|
|
392
|
-
'--dry-color-fill-inverse-active',
|
|
393
|
-
'--dry-color-fill-inverse-disabled',
|
|
394
|
-
'--dry-color-fill-white',
|
|
395
|
-
'--dry-color-fill-yellow',
|
|
396
|
-
'--dry-color-bg-sunken',
|
|
397
|
-
'--dry-color-bg-alternate',
|
|
398
|
-
'--dry-color-bg-brand',
|
|
399
|
-
'--dry-color-bg-inverse'
|
|
400
|
-
];
|
|
401
|
-
for (const key of semanticParityKeys) {
|
|
402
|
-
expect(light[key]).toBeDefined();
|
|
403
|
-
expect(dark[key]).toBeDefined();
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
});
|
|
407
|
-
describe('generateTheme — neutrals', () => {
|
|
408
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
409
|
-
const { light, dark } = generateTheme(brand);
|
|
410
|
-
const neutralKeys = [
|
|
411
|
-
'--dry-color-text-strong',
|
|
412
|
-
'--dry-color-text-weak',
|
|
413
|
-
'--dry-color-icon',
|
|
414
|
-
'--dry-color-stroke-strong',
|
|
415
|
-
'--dry-color-stroke-weak',
|
|
416
|
-
'--dry-color-fill',
|
|
417
|
-
'--dry-color-fill-hover',
|
|
418
|
-
'--dry-color-fill-active'
|
|
419
|
-
];
|
|
420
|
-
test('all 8 neutral tokens exist in light mode', () => {
|
|
421
|
-
for (const key of neutralKeys) {
|
|
422
|
-
expect(light[key]).toBeDefined();
|
|
423
|
-
}
|
|
424
|
-
});
|
|
425
|
-
test('all 8 neutral tokens exist in dark mode', () => {
|
|
426
|
-
for (const key of neutralKeys) {
|
|
427
|
-
expect(dark[key]).toBeDefined();
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
test('light neutrals contain brand hue (220)', () => {
|
|
431
|
-
for (const key of neutralKeys) {
|
|
432
|
-
// Should start with hsla(220, ...) — brand hue-tinted
|
|
433
|
-
expect(light[key]).toMatch(/^hsla\(220,/);
|
|
434
|
-
}
|
|
435
|
-
});
|
|
436
|
-
test('dark neutrals are white-based (hue 0)', () => {
|
|
437
|
-
for (const key of neutralKeys) {
|
|
438
|
-
// White-based: hsla(0, 0%, 100%, ...)
|
|
439
|
-
expect(dark[key]).toMatch(/^hsla\(0,/);
|
|
440
|
-
}
|
|
441
|
-
});
|
|
442
|
-
test('neutral-text-strong has highest alpha in light mode', () => {
|
|
443
|
-
// text-strong = 0.90, text-weak = 0.65 — text-strong alpha > text-weak alpha
|
|
444
|
-
const textStrong = light['--dry-color-text-strong'];
|
|
445
|
-
const textWeak = light['--dry-color-text-weak'];
|
|
446
|
-
const alphaStrong = parseFloat(textStrong.split(', ').pop().replace(')', ''));
|
|
447
|
-
const alphaWeak = parseFloat(textWeak.split(', ').pop().replace(')', ''));
|
|
448
|
-
expect(alphaStrong).toBeGreaterThan(alphaWeak);
|
|
449
|
-
});
|
|
450
|
-
test('text-strong uses lightness 15% in light mode', () => {
|
|
451
|
-
// text-strong should use 15% lightness, not 20%
|
|
452
|
-
const textStrong = light['--dry-color-text-strong'];
|
|
453
|
-
expect(textStrong).toMatch(/^hsla\(220, 100%, 15%,/);
|
|
454
|
-
});
|
|
455
|
-
test('other neutrals use lightness 20% in light mode', () => {
|
|
456
|
-
// All non-text-strong neutrals should use 20% lightness
|
|
457
|
-
for (const key of neutralKeys.filter((k) => k !== '--dry-color-text-strong')) {
|
|
458
|
-
expect(light[key]).toMatch(/^hsla\(220, 100%, 20%,/);
|
|
459
|
-
}
|
|
460
|
-
});
|
|
461
|
-
});
|
|
462
|
-
describe('generateTheme — neutral versus monochromatic greys', () => {
|
|
463
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
464
|
-
const monochromaticTheme = generateTheme(brand);
|
|
465
|
-
const neutralTheme = generateTheme(brand, { neutralMode: 'neutral' });
|
|
466
|
-
test('monochromatic mode keeps the brand hue in light neutrals', () => {
|
|
467
|
-
expect(monochromaticTheme.light['--dry-color-text-strong']).toMatch(/^hsla\(220, 100%, 15%,/);
|
|
468
|
-
expect(monochromaticTheme.dark['--dry-color-bg-base']).toBe('hsl(220, 30%, 10%)');
|
|
469
|
-
});
|
|
470
|
-
test('neutral mode uses brand-agnostic light neutrals', () => {
|
|
471
|
-
expect(neutralTheme.light['--dry-color-text-strong']).toMatch(/^hsla\(0, 0%, 15%,/);
|
|
472
|
-
expect(neutralTheme.light['--dry-color-fill']).toMatch(/^hsla\(0, 0%, 20%,/);
|
|
473
|
-
});
|
|
474
|
-
test('neutral mode uses neutral dark surfaces', () => {
|
|
475
|
-
expect(neutralTheme.dark['--dry-color-bg-base']).toBe('hsl(0, 0%, 10%)');
|
|
476
|
-
expect(neutralTheme.dark['--dry-color-bg-raised']).toBe('hsl(0, 0%, 15%)');
|
|
477
|
-
expect(neutralTheme.dark['--dry-color-bg-overlay']).toBe('hsl(0, 0%, 20%)');
|
|
478
|
-
});
|
|
479
|
-
test('neutral mode still derives dark text-brand against the actual dark base', () => {
|
|
480
|
-
const textBrand = neutralTheme.dark['--dry-color-text-brand'];
|
|
481
|
-
const [, saturation, lightness] = textBrand.match(/^hsl\(\d+,\s*([\d.]+)%,\s*([\d.]+)%\)$/) ?? [];
|
|
482
|
-
expect(saturation).toBeDefined();
|
|
483
|
-
expect(lightness).toBeDefined();
|
|
484
|
-
const textLum = luminanceFromHsl(220, Number(saturation) / 100, Number(lightness) / 100);
|
|
485
|
-
const baseLum = luminanceFromHsl(0, 0, 0.1);
|
|
486
|
-
expect(contrastRatio(textLum, baseLum)).toBeGreaterThanOrEqual(4.5);
|
|
487
|
-
});
|
|
488
|
-
});
|
|
489
|
-
describe('generateTheme — brand tokens', () => {
|
|
490
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
491
|
-
const { light, dark } = generateTheme(brand);
|
|
492
|
-
const brandKeys = [
|
|
493
|
-
'--dry-color-brand',
|
|
494
|
-
'--dry-color-text-brand',
|
|
495
|
-
'--dry-color-fill-brand',
|
|
496
|
-
'--dry-color-fill-brand-hover',
|
|
497
|
-
'--dry-color-fill-brand-active',
|
|
498
|
-
'--dry-color-fill-brand-weak',
|
|
499
|
-
'--dry-color-stroke-brand',
|
|
500
|
-
'--dry-color-on-brand',
|
|
501
|
-
'--dry-color-focus-ring'
|
|
502
|
-
];
|
|
503
|
-
test('all 9 brand tokens present in light', () => {
|
|
504
|
-
for (const key of brandKeys) {
|
|
505
|
-
expect(light[key]).toBeDefined();
|
|
506
|
-
}
|
|
507
|
-
});
|
|
508
|
-
test('all 9 brand tokens present in dark', () => {
|
|
509
|
-
for (const key of brandKeys) {
|
|
510
|
-
expect(dark[key]).toBeDefined();
|
|
511
|
-
}
|
|
512
|
-
});
|
|
513
|
-
test('on-brand picks correct contrast color', () => {
|
|
514
|
-
// For a mid-range blue (HSB 220, 80, 70) the fill is moderate lightness
|
|
515
|
-
// on-brand should be either white or a dark tint
|
|
516
|
-
const onBrand = light['--dry-color-on-brand'];
|
|
517
|
-
expect(onBrand === '#ffffff' || onBrand.startsWith('hsl(')).toBe(true);
|
|
518
|
-
});
|
|
519
|
-
test('on-brand for a very light brand color should NOT be white', () => {
|
|
520
|
-
// Very light brand color: high brightness, low saturation
|
|
521
|
-
const lightBrand = { h: 60, s: 10, b: 97 };
|
|
522
|
-
const { light: lt } = generateTheme(lightBrand);
|
|
523
|
-
// A very light color should use dark text for readability
|
|
524
|
-
// White on near-white would fail contrast
|
|
525
|
-
expect(lt['--dry-color-on-brand']).not.toBe('#ffffff');
|
|
526
|
-
});
|
|
527
|
-
test('on-brand for a very dark brand color should be white', () => {
|
|
528
|
-
// Very dark brand color: low brightness
|
|
529
|
-
const darkBrand = { h: 220, s: 90, b: 20 };
|
|
530
|
-
const { light: lt } = generateTheme(darkBrand);
|
|
531
|
-
expect(lt['--dry-color-on-brand']).toBe('#ffffff');
|
|
532
|
-
});
|
|
533
|
-
test('fill-brand-hover is darker than fill-brand in light mode', () => {
|
|
534
|
-
// fill-brand-hover = L-8% in light mode
|
|
535
|
-
const fillBrand = light['--dry-color-fill-brand'];
|
|
536
|
-
const fillBrandHover = light['--dry-color-fill-brand-hover'];
|
|
537
|
-
// Both are hsl() strings — extract lightness
|
|
538
|
-
const lBrand = parseFloat(fillBrand.match(/,\s*([\d.]+)%\)/)[1]);
|
|
539
|
-
const lHover = parseFloat(fillBrandHover.match(/,\s*([\d.]+)%\)/)[1]);
|
|
540
|
-
expect(lHover).toBeLessThan(lBrand);
|
|
541
|
-
});
|
|
542
|
-
test('fill-brand-hover is lighter than fill-brand in dark mode', () => {
|
|
543
|
-
const fillBrand = dark['--dry-color-fill-brand'];
|
|
544
|
-
const fillBrandHover = dark['--dry-color-fill-brand-hover'];
|
|
545
|
-
const lBrand = parseFloat(fillBrand.match(/,\s*([\d.]+)%\)/)[1]);
|
|
546
|
-
const lHover = parseFloat(fillBrandHover.match(/,\s*([\d.]+)%\)/)[1]);
|
|
547
|
-
expect(lHover).toBeGreaterThan(lBrand);
|
|
548
|
-
});
|
|
549
|
-
test('fill-brand-active is darker than fill-brand in dark mode', () => {
|
|
550
|
-
// fill-brand-active = L-6% in dark mode (pressed always darkens)
|
|
551
|
-
const fillBrand = dark['--dry-color-fill-brand'];
|
|
552
|
-
const fillBrandActive = dark['--dry-color-fill-brand-active'];
|
|
553
|
-
const lBrand = parseFloat(fillBrand.match(/,\s*([\d.]+)%\)/)[1]);
|
|
554
|
-
const lActive = parseFloat(fillBrandActive.match(/,\s*([\d.]+)%\)/)[1]);
|
|
555
|
-
expect(lActive).toBeLessThan(lBrand);
|
|
556
|
-
});
|
|
557
|
-
test('dark brand fill is brighter and less saturated in HSB terms', () => {
|
|
558
|
-
const fillBrandLight = light['--dry-color-fill-brand'];
|
|
559
|
-
const fillBrandDark = dark['--dry-color-fill-brand'];
|
|
560
|
-
const parseHsl = (value) => {
|
|
561
|
-
const match = value.match(/hsl\(([\d.]+),\s*([\d.]+)%,\s*([\d.]+)%\)/);
|
|
562
|
-
if (!match?.[1] || !match[2] || !match[3]) {
|
|
563
|
-
throw new Error(`Unexpected HSL color: ${value}`);
|
|
564
|
-
}
|
|
565
|
-
return {
|
|
566
|
-
h: Number.parseFloat(match[1]),
|
|
567
|
-
s: Number.parseFloat(match[2]) / 100,
|
|
568
|
-
l: Number.parseFloat(match[3]) / 100
|
|
569
|
-
};
|
|
570
|
-
};
|
|
571
|
-
const lightHsl = parseHsl(fillBrandLight);
|
|
572
|
-
const darkHsl = parseHsl(fillBrandDark);
|
|
573
|
-
const lightHsb = hslToHsb(lightHsl.h, lightHsl.s, lightHsl.l);
|
|
574
|
-
const darkHsb = hslToHsb(darkHsl.h, darkHsl.s, darkHsl.l);
|
|
575
|
-
expect(darkHsb.b).toBeGreaterThan(lightHsb.b);
|
|
576
|
-
expect(darkHsb.s).toBeLessThan(lightHsb.s);
|
|
577
|
-
});
|
|
578
|
-
test('text-brand achieves 4.5:1 contrast on white background (light mode)', () => {
|
|
579
|
-
const textBrand = light['--dry-color-text-brand'];
|
|
580
|
-
// Parse hsl() string
|
|
581
|
-
const match = textBrand.match(/hsl\((\d+),\s*([\d.]+)%,\s*([\d.]+)%\)/);
|
|
582
|
-
if (!match)
|
|
583
|
-
throw new Error(`Unexpected text-brand format: ${textBrand}`);
|
|
584
|
-
const [, h, s, l] = match.map(Number);
|
|
585
|
-
const lum = luminanceFromHsl(h, s / 100, l / 100);
|
|
586
|
-
const contrast = contrastRatio(lum, 1.0);
|
|
587
|
-
expect(contrast).toBeGreaterThanOrEqual(4.4); // slight tolerance for rounding
|
|
588
|
-
});
|
|
589
|
-
test('dark focus-ring has higher lightness than light focus-ring', () => {
|
|
590
|
-
// Dark mode bumps L by +10%
|
|
591
|
-
const lightRing = light['--dry-color-focus-ring'];
|
|
592
|
-
const darkRing = dark['--dry-color-focus-ring'];
|
|
593
|
-
// hsla format: hsla(H, S%, L%, A) — extract the second % value (lightness)
|
|
594
|
-
const extractLightness = (s) => {
|
|
595
|
-
const matches = [...s.matchAll(/(\d+(?:\.\d+)?)%/g)];
|
|
596
|
-
return parseFloat(matches[1][1]); // index 1 = lightness (after saturation)
|
|
597
|
-
};
|
|
598
|
-
const lLight = extractLightness(lightRing);
|
|
599
|
-
const lDark = extractLightness(darkRing);
|
|
600
|
-
expect(lDark).toBeGreaterThan(lLight);
|
|
601
|
-
});
|
|
602
|
-
});
|
|
603
|
-
describe('foreground surface comparison helpers', () => {
|
|
604
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
605
|
-
const theme = generateTheme(brand);
|
|
606
|
-
test('measures contrast and APCA for one surface', () => {
|
|
607
|
-
const assessment = measureForegroundOnSurface(theme.light['--dry-color-text-weak'], theme.light['--dry-color-bg-base']);
|
|
608
|
-
expect(assessment.foreground).toBe(theme.light['--dry-color-text-weak']);
|
|
609
|
-
expect(assessment.surface).toBe(theme.light['--dry-color-bg-base']);
|
|
610
|
-
expect(assessment.contrast).not.toBeNull();
|
|
611
|
-
expect(assessment.apca).not.toBeNull();
|
|
612
|
-
expect(assessment.apcaMagnitude).toBe(Math.abs(assessment.apca ?? 0));
|
|
613
|
-
});
|
|
614
|
-
test('compares the same foreground token across multiple surfaces', () => {
|
|
615
|
-
const comparison = compareForegroundAcrossSurfaces(theme.light['--dry-color-text-weak'], [
|
|
616
|
-
theme.light['--dry-color-bg-base'],
|
|
617
|
-
theme.dark['--dry-color-bg-base'],
|
|
618
|
-
theme.dark['--dry-color-bg-raised']
|
|
619
|
-
]);
|
|
620
|
-
expect(comparison.foreground).toBe(theme.light['--dry-color-text-weak']);
|
|
621
|
-
expect(comparison.assessments).toHaveLength(3);
|
|
622
|
-
expect(comparison.contrastSpread).not.toBeNull();
|
|
623
|
-
expect(comparison.apcaMagnitudeSpread).not.toBeNull();
|
|
624
|
-
});
|
|
625
|
-
test('treats transparent fills differently from opaque fills', () => {
|
|
626
|
-
const solid = measureForegroundOnSurface(theme.light['--dry-color-fill-brand'], theme.light['--dry-color-bg-base'], { contrast: 0, apca: 0 });
|
|
627
|
-
const transparent = measureForegroundOnSurface(theme.light['--dry-color-fill-brand-weak'], theme.light['--dry-color-bg-base'], { contrast: 0, apca: 0 });
|
|
628
|
-
expect(solid.contrast).not.toBeNull();
|
|
629
|
-
expect(transparent.contrast).not.toBeNull();
|
|
630
|
-
expect((transparent.contrast ?? 0) < (solid.contrast ?? 0)).toBe(true);
|
|
631
|
-
expect((transparent.apcaMagnitude ?? 0) < (solid.apcaMagnitude ?? 0)).toBe(true);
|
|
632
|
-
});
|
|
633
|
-
});
|
|
634
|
-
describe('generateTheme — backgrounds', () => {
|
|
635
|
-
const brand = { h: 30, s: 60, b: 80 };
|
|
636
|
-
const { light, dark } = generateTheme(brand);
|
|
637
|
-
test('light backgrounds are white', () => {
|
|
638
|
-
expect(light['--dry-color-bg-base']).toBe('#ffffff');
|
|
639
|
-
expect(light['--dry-color-bg-raised']).toBe('#ffffff');
|
|
640
|
-
expect(light['--dry-color-bg-overlay']).toBe('#ffffff');
|
|
641
|
-
});
|
|
642
|
-
test('dark backgrounds are dark and hue-tinted', () => {
|
|
643
|
-
// Should be hsl() strings
|
|
644
|
-
expect(dark['--dry-color-bg-base']).toMatch(/^hsl\(/);
|
|
645
|
-
expect(dark['--dry-color-bg-raised']).toMatch(/^hsl\(/);
|
|
646
|
-
expect(dark['--dry-color-bg-overlay']).toMatch(/^hsl\(/);
|
|
647
|
-
});
|
|
648
|
-
test('dark bg-base is darker than bg-raised', () => {
|
|
649
|
-
const base = dark['--dry-color-bg-base'];
|
|
650
|
-
const raised = dark['--dry-color-bg-raised'];
|
|
651
|
-
const lBase = parseFloat(base.match(/,\s*([\d.]+)%\)/)[1]);
|
|
652
|
-
const lRaised = parseFloat(raised.match(/,\s*([\d.]+)%\)/)[1]);
|
|
653
|
-
expect(lBase).toBeLessThan(lRaised);
|
|
654
|
-
});
|
|
655
|
-
test('custom dark bg overrides are respected', () => {
|
|
656
|
-
const { dark: customDark } = generateTheme(brand, {
|
|
657
|
-
darkBg: { base: '#111111', raised: '#222222', overlay: '#333333' }
|
|
658
|
-
});
|
|
659
|
-
expect(customDark['--dry-color-bg-base']).toBe('#111111');
|
|
660
|
-
expect(customDark['--dry-color-bg-raised']).toBe('#222222');
|
|
661
|
-
expect(customDark['--dry-color-bg-overlay']).toBe('#333333');
|
|
662
|
-
});
|
|
663
|
-
test('semantic surface helpers mirror the solid surface ladder and inverse base', () => {
|
|
664
|
-
expect(light['--dry-color-bg-sunken']).not.toBe(light['--dry-color-bg-base']);
|
|
665
|
-
expect(light['--dry-color-bg-alternate']).toBe(light['--dry-color-bg-sunken']);
|
|
666
|
-
expect(light['--dry-color-bg-brand']).toBe(light['--dry-color-fill-brand']);
|
|
667
|
-
expect(light['--dry-color-bg-inverse']).toBe(dark['--dry-color-bg-base']);
|
|
668
|
-
expect(dark['--dry-color-bg-inverse']).toBe(light['--dry-color-bg-base']);
|
|
669
|
-
});
|
|
670
|
-
});
|
|
671
|
-
describe('generateTheme — semantic parity helpers', () => {
|
|
672
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
673
|
-
const { light, dark } = generateTheme(brand);
|
|
674
|
-
test('disabled neutrals resolve to the weak stroke treatment', () => {
|
|
675
|
-
expect(light['--dry-color-text-disabled']).toBe(light['--dry-color-stroke-weak']);
|
|
676
|
-
expect(light['--dry-color-icon-disabled']).toBe(light['--dry-color-stroke-weak']);
|
|
677
|
-
expect(light['--dry-color-fill-disabled']).toBe(light['--dry-color-stroke-weak']);
|
|
678
|
-
expect(dark['--dry-color-text-disabled']).toBe(dark['--dry-color-stroke-weak']);
|
|
679
|
-
});
|
|
680
|
-
test('inverse families reuse the opposite mode semantic neutrals', () => {
|
|
681
|
-
expect(light['--dry-color-text-inverse']).toBe(dark['--dry-color-text-strong']);
|
|
682
|
-
expect(light['--dry-color-stroke-inverse']).toBe(dark['--dry-color-stroke-strong']);
|
|
683
|
-
expect(light['--dry-color-fill-inverse']).toBe(light['--dry-color-text-inverse']);
|
|
684
|
-
expect(dark['--dry-color-text-inverse']).toBe(light['--dry-color-text-strong']);
|
|
685
|
-
expect(dark['--dry-color-stroke-inverse']).toBe(light['--dry-color-stroke-strong']);
|
|
686
|
-
expect(dark['--dry-color-fill-inverse']).toBe(dark['--dry-color-text-inverse']);
|
|
687
|
-
});
|
|
688
|
-
test('utility fills stay explicit', () => {
|
|
689
|
-
expect(light['--dry-color-fill-white']).toBe('#ffffff');
|
|
690
|
-
expect(light['--dry-color-fill-yellow']).toBe('#fec62e');
|
|
691
|
-
expect(dark['--dry-color-fill-yellow']).toBe('#fec62e');
|
|
692
|
-
});
|
|
693
|
-
});
|
|
694
|
-
describe('generateTheme — status tokens', () => {
|
|
695
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
696
|
-
const { light, dark } = generateTheme(brand);
|
|
697
|
-
const tones = ['error', 'warning', 'success', 'info'];
|
|
698
|
-
const tokenSuffixes = [
|
|
699
|
-
'--dry-color-text-{tone}',
|
|
700
|
-
'--dry-color-fill-{tone}',
|
|
701
|
-
'--dry-color-fill-{tone}-hover',
|
|
702
|
-
'--dry-color-fill-{tone}-weak',
|
|
703
|
-
'--dry-color-stroke-{tone}',
|
|
704
|
-
'--dry-color-on-{tone}'
|
|
705
|
-
];
|
|
706
|
-
test('all 4 status tones are present', () => {
|
|
707
|
-
for (const tone of tones) {
|
|
708
|
-
expect(light[`--dry-color-fill-${tone}`]).toBeDefined();
|
|
709
|
-
expect(dark[`--dry-color-fill-${tone}`]).toBeDefined();
|
|
710
|
-
}
|
|
711
|
-
});
|
|
712
|
-
test('all 6 token types per tone exist in light mode', () => {
|
|
713
|
-
for (const tone of tones) {
|
|
714
|
-
for (const suffix of tokenSuffixes) {
|
|
715
|
-
const key = suffix.replace('{tone}', tone);
|
|
716
|
-
expect(light[key]).toBeDefined();
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
});
|
|
720
|
-
test('all 6 token types per tone exist in dark mode', () => {
|
|
721
|
-
for (const tone of tones) {
|
|
722
|
-
for (const suffix of tokenSuffixes) {
|
|
723
|
-
const key = suffix.replace('{tone}', tone);
|
|
724
|
-
expect(dark[key]).toBeDefined();
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
});
|
|
728
|
-
test('32 status tokens total (core tone set plus icon and strong-stroke helpers)', () => {
|
|
729
|
-
let count = 0;
|
|
730
|
-
for (const key of Object.keys(light)) {
|
|
731
|
-
if (tones.some((t) => key.includes(t)))
|
|
732
|
-
count++;
|
|
733
|
-
}
|
|
734
|
-
expect(count).toBe(32);
|
|
735
|
-
});
|
|
736
|
-
test('error fill has red-ish hue (hue 0)', () => {
|
|
737
|
-
const fillError = light['--dry-color-fill-error'];
|
|
738
|
-
expect(fillError).toMatch(/^hsl\(0,/);
|
|
739
|
-
});
|
|
740
|
-
test('success fill has green-ish hue (hue 145)', () => {
|
|
741
|
-
const fillSuccess = light['--dry-color-fill-success'];
|
|
742
|
-
expect(fillSuccess).toMatch(/^hsl\(145,/);
|
|
743
|
-
});
|
|
744
|
-
test('custom status hues are respected', () => {
|
|
745
|
-
const { light: customLight } = generateTheme(brand, {
|
|
746
|
-
statusHues: { error: 15, warning: 50, success: 130, info: 200 }
|
|
747
|
-
});
|
|
748
|
-
expect(customLight['--dry-color-fill-error']).toMatch(/^hsl\(15,/);
|
|
749
|
-
expect(customLight['--dry-color-fill-warning']).toMatch(/^hsl\(50,/);
|
|
750
|
-
expect(customLight['--dry-color-fill-success']).toMatch(/^hsl\(130,/);
|
|
751
|
-
expect(customLight['--dry-color-fill-info']).toMatch(/^hsl\(200,/);
|
|
752
|
-
});
|
|
753
|
-
test('fill-tone-weak uses alpha', () => {
|
|
754
|
-
for (const tone of tones) {
|
|
755
|
-
expect(light[`--dry-color-fill-${tone}-weak`]).toMatch(/^hsla\(/);
|
|
756
|
-
expect(dark[`--dry-color-fill-${tone}-weak`]).toMatch(/^hsla\(/);
|
|
757
|
-
}
|
|
758
|
-
});
|
|
759
|
-
});
|
|
760
|
-
describe('generateTheme — shadows', () => {
|
|
761
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
762
|
-
const { light, dark } = generateTheme(brand);
|
|
763
|
-
test('--dry-shadow-raised and --dry-shadow-overlay exist in both modes', () => {
|
|
764
|
-
expect(light['--dry-shadow-raised']).toBeDefined();
|
|
765
|
-
expect(light['--dry-shadow-overlay']).toBeDefined();
|
|
766
|
-
expect(dark['--dry-shadow-raised']).toBeDefined();
|
|
767
|
-
expect(dark['--dry-shadow-overlay']).toBeDefined();
|
|
768
|
-
});
|
|
769
|
-
test('shadows are full box-shadow shorthand (contain offset and blur values)', () => {
|
|
770
|
-
// Full shorthand starts with offset values like "0 1px 3px ..."
|
|
771
|
-
expect(light['--dry-shadow-raised']).toMatch(/^0 \d+px \d+px hsla\(/);
|
|
772
|
-
expect(light['--dry-shadow-overlay']).toMatch(/^0 \d+px \d+px hsla\(/);
|
|
773
|
-
expect(dark['--dry-shadow-raised']).toMatch(/^0 \d+px \d+px hsla\(/);
|
|
774
|
-
expect(dark['--dry-shadow-overlay']).toMatch(/^0 \d+px \d+px hsla\(/);
|
|
775
|
-
});
|
|
776
|
-
test('shadows contain two layers (comma-separated)', () => {
|
|
777
|
-
expect(light['--dry-shadow-raised'].split(', 0 ').length).toBe(2);
|
|
778
|
-
expect(light['--dry-shadow-overlay'].split(', 0 ').length).toBe(2);
|
|
779
|
-
expect(dark['--dry-shadow-raised'].split(', 0 ').length).toBe(2);
|
|
780
|
-
expect(dark['--dry-shadow-overlay'].split(', 0 ').length).toBe(2);
|
|
781
|
-
});
|
|
782
|
-
test('dark shadows have higher alpha than light shadows', () => {
|
|
783
|
-
// Extract the last alpha value from the last hsla() in each shadow
|
|
784
|
-
const extractLastAlpha = (s) => {
|
|
785
|
-
const matches = [...s.matchAll(/hsla\([^)]+,\s*([\d.]+)\)/g)];
|
|
786
|
-
return parseFloat(matches[matches.length - 1][1]);
|
|
787
|
-
};
|
|
788
|
-
const lightAlpha = extractLastAlpha(light['--dry-shadow-raised']);
|
|
789
|
-
const darkAlpha = extractLastAlpha(dark['--dry-shadow-raised']);
|
|
790
|
-
expect(darkAlpha).toBeGreaterThan(lightAlpha);
|
|
791
|
-
});
|
|
792
|
-
test('shadows are brand-hue-tinted', () => {
|
|
793
|
-
// Should contain brand hue (220)
|
|
794
|
-
expect(light['--dry-shadow-raised']).toContain('220');
|
|
795
|
-
expect(dark['--dry-shadow-raised']).toContain('220');
|
|
796
|
-
});
|
|
797
|
-
test('light raised shadow matches expected shorthand', () => {
|
|
798
|
-
expect(light['--dry-shadow-raised']).toBe('0 1px 3px hsla(220, 20%, 20%, 0.08), 0 1px 2px hsla(220, 20%, 20%, 0.06)');
|
|
799
|
-
});
|
|
800
|
-
test('dark raised shadow matches expected shorthand', () => {
|
|
801
|
-
expect(dark['--dry-shadow-raised']).toBe('0 1px 3px hsla(220, 30%, 5%, 0.4), 0 1px 2px hsla(220, 30%, 5%, 0.3)');
|
|
802
|
-
});
|
|
803
|
-
test('light overlay shadow matches expected shorthand', () => {
|
|
804
|
-
expect(light['--dry-shadow-overlay']).toBe('0 8px 24px hsla(220, 20%, 20%, 0.12), 0 2px 8px hsla(220, 20%, 20%, 0.08)');
|
|
805
|
-
});
|
|
806
|
-
test('dark overlay shadow matches expected shorthand', () => {
|
|
807
|
-
expect(dark['--dry-shadow-overlay']).toBe('0 8px 24px hsla(220, 30%, 5%, 0.5), 0 2px 8px hsla(220, 30%, 5%, 0.4)');
|
|
808
|
-
});
|
|
809
|
-
});
|
|
810
|
-
describe('generateTheme — overlay backdrops', () => {
|
|
811
|
-
const brand = { h: 220, s: 80, b: 70 };
|
|
812
|
-
const { light, dark } = generateTheme(brand);
|
|
813
|
-
test('--dry-color-overlay-backdrop and --dry-color-overlay-backdrop-strong exist in both modes', () => {
|
|
814
|
-
expect(light['--dry-color-overlay-backdrop']).toBeDefined();
|
|
815
|
-
expect(light['--dry-color-overlay-backdrop-strong']).toBeDefined();
|
|
816
|
-
expect(dark['--dry-color-overlay-backdrop']).toBeDefined();
|
|
817
|
-
expect(dark['--dry-color-overlay-backdrop-strong']).toBeDefined();
|
|
818
|
-
});
|
|
819
|
-
test('old backdrop-sm / backdrop-lg keys are gone', () => {
|
|
820
|
-
expect(light['--dry-color-backdrop-sm']).toBeUndefined();
|
|
821
|
-
expect(light['--dry-color-backdrop-lg']).toBeUndefined();
|
|
822
|
-
expect(dark['--dry-color-backdrop-sm']).toBeUndefined();
|
|
823
|
-
expect(dark['--dry-color-backdrop-lg']).toBeUndefined();
|
|
824
|
-
});
|
|
825
|
-
test('dark backdrops are more opaque than light backdrops', () => {
|
|
826
|
-
const lightAlpha = parseFloat(light['--dry-color-overlay-backdrop'].split(', ').pop().replace(')', ''));
|
|
827
|
-
const darkAlpha = parseFloat(dark['--dry-color-overlay-backdrop'].split(', ').pop().replace(')', ''));
|
|
828
|
-
expect(darkAlpha).toBeGreaterThan(lightAlpha);
|
|
829
|
-
});
|
|
830
|
-
test('overlay-backdrop-strong is more opaque than overlay-backdrop in each mode', () => {
|
|
831
|
-
const lightBase = parseFloat(light['--dry-color-overlay-backdrop'].split(', ').pop().replace(')', ''));
|
|
832
|
-
const lightStrong = parseFloat(light['--dry-color-overlay-backdrop-strong'].split(', ').pop().replace(')', ''));
|
|
833
|
-
expect(lightStrong).toBeGreaterThan(lightBase);
|
|
834
|
-
const darkBase = parseFloat(dark['--dry-color-overlay-backdrop'].split(', ').pop().replace(')', ''));
|
|
835
|
-
const darkStrong = parseFloat(dark['--dry-color-overlay-backdrop-strong'].split(', ').pop().replace(')', ''));
|
|
836
|
-
expect(darkStrong).toBeGreaterThan(darkBase);
|
|
837
|
-
});
|
|
838
|
-
});
|
|
839
|
-
describe('generateTheme — edge cases', () => {
|
|
840
|
-
test('works with pure hue (fully saturated)', () => {
|
|
841
|
-
const brand = { h: 0, s: 100, b: 100 };
|
|
842
|
-
expect(() => generateTheme(brand)).not.toThrow();
|
|
843
|
-
const { light, dark } = generateTheme(brand);
|
|
844
|
-
expect(Object.keys(light).length).toBeGreaterThan(0);
|
|
845
|
-
expect(Object.keys(dark).length).toBeGreaterThan(0);
|
|
846
|
-
});
|
|
847
|
-
test('works with achromatic brand (s=0)', () => {
|
|
848
|
-
const brand = { h: 0, s: 0, b: 50 };
|
|
849
|
-
expect(() => generateTheme(brand)).not.toThrow();
|
|
850
|
-
const { light } = generateTheme(brand);
|
|
851
|
-
expect(light['--dry-color-brand']).toBeDefined();
|
|
852
|
-
});
|
|
853
|
-
test('works with maximum brightness brand', () => {
|
|
854
|
-
const brand = { h: 180, s: 50, b: 100 };
|
|
855
|
-
expect(() => generateTheme(brand)).not.toThrow();
|
|
856
|
-
});
|
|
857
|
-
test('works with minimum brightness brand', () => {
|
|
858
|
-
const brand = { h: 180, s: 50, b: 5 };
|
|
859
|
-
expect(() => generateTheme(brand)).not.toThrow();
|
|
860
|
-
});
|
|
861
|
-
test('hue wrapping: H=360 treated as H=0', () => {
|
|
862
|
-
const { light: l360 } = generateTheme({ h: 360, s: 70, b: 60 });
|
|
863
|
-
const { light: l0 } = generateTheme({ h: 0, s: 70, b: 60 });
|
|
864
|
-
// Should produce identical output since hue modulo 360 = same color
|
|
865
|
-
expect(l360['--dry-color-fill-brand']).toBe(l0['--dry-color-fill-brand']);
|
|
866
|
-
});
|
|
867
|
-
});
|
|
868
|
-
describe('generateThemeModel', () => {
|
|
869
|
-
test('exposes a first-class primitive, _Theme, and semantic chain', () => {
|
|
870
|
-
const model = generateThemeModel({ h: 230, s: 65, b: 85 });
|
|
871
|
-
expect(model.primitives.light['brand.fill']).toBe(model.tokens.light['--dry-color-fill-brand']);
|
|
872
|
-
expect(model.transparentPrimitives.neutral.light.fill).toBe(model.tokens.light['--dry-color-fill']);
|
|
873
|
-
expect(model._theme.light['Theme/Brand/Fill']).toEqual({
|
|
874
|
-
source: 'brand.fill',
|
|
875
|
-
value: model.tokens.light['--dry-color-fill-brand']
|
|
876
|
-
});
|
|
877
|
-
expect(model.semantic.light['--dry-color-fill-brand']).toEqual({
|
|
878
|
-
source: 'Theme/Brand/Fill',
|
|
879
|
-
value: model.tokens.light['--dry-color-fill-brand']
|
|
880
|
-
});
|
|
881
|
-
});
|
|
882
|
-
test('keeps the semantic output identical to generateTheme', () => {
|
|
883
|
-
const tokens = generateTheme({ h: 230, s: 65, b: 85 }, { neutralMode: 'neutral' });
|
|
884
|
-
const model = generateThemeModel({ h: 230, s: 65, b: 85 }, { neutralMode: 'neutral' });
|
|
885
|
-
expect(model.tokens).toEqual(tokens);
|
|
886
|
-
expect(model.semantic.dark['--dry-color-bg-overlay']).toEqual({
|
|
887
|
-
source: 'Theme/Surface/Overlay',
|
|
888
|
-
value: tokens.dark['--dry-color-bg-overlay']
|
|
889
|
-
});
|
|
890
|
-
});
|
|
891
|
-
test('keeps Practical UI naming translations explicit in the semantic chain', () => {
|
|
892
|
-
const model = generateThemeModel({ h: 230, s: 65, b: 85 });
|
|
893
|
-
expect(model.semantic.light['--dry-color-text-info']).toEqual({
|
|
894
|
-
source: 'Theme/Info/Text',
|
|
895
|
-
value: model.tokens.light['--dry-color-text-info']
|
|
896
|
-
});
|
|
897
|
-
expect(model.semantic.light['--dry-color-fill-active']).toEqual({
|
|
898
|
-
source: 'Theme/Neutral/Fill/Active',
|
|
899
|
-
value: model.tokens.light['--dry-color-fill-active']
|
|
900
|
-
});
|
|
901
|
-
expect(model.semantic.light['--dry-color-bg-base']).toEqual({
|
|
902
|
-
source: 'Theme/Surface/Base',
|
|
903
|
-
value: model.tokens.light['--dry-color-bg-base']
|
|
904
|
-
});
|
|
905
|
-
});
|
|
906
|
-
test('maps the expanded semantic parity roles through _Theme', () => {
|
|
907
|
-
const model = generateThemeModel({ h: 230, s: 65, b: 85 });
|
|
908
|
-
expect(model._theme.light['Theme/Surface/Sunken']).toEqual({
|
|
909
|
-
source: 'surface.sunken',
|
|
910
|
-
value: model.tokens.light['--dry-color-bg-sunken']
|
|
911
|
-
});
|
|
912
|
-
expect(model.semantic.light['--dry-color-text-disabled']).toEqual({
|
|
913
|
-
source: 'Theme/Neutral/Text/Disabled',
|
|
914
|
-
value: model.tokens.light['--dry-color-text-disabled']
|
|
915
|
-
});
|
|
916
|
-
expect(model.semantic.light['--dry-color-fill-inverse-hover']).toEqual({
|
|
917
|
-
source: 'Theme/Neutral/Inverse/Fill/Hover',
|
|
918
|
-
value: model.tokens.light['--dry-color-fill-inverse-hover']
|
|
919
|
-
});
|
|
920
|
-
expect(model.semantic.light['--dry-color-icon-brand']).toEqual({
|
|
921
|
-
source: 'Theme/Brand/Icon',
|
|
922
|
-
value: model.tokens.light['--dry-color-icon-brand']
|
|
923
|
-
});
|
|
924
|
-
expect(model.semantic.light['--dry-color-stroke-info-strong']).toEqual({
|
|
925
|
-
source: 'Theme/Info/Stroke/Strong',
|
|
926
|
-
value: model.tokens.light['--dry-color-stroke-info-strong']
|
|
927
|
-
});
|
|
928
|
-
});
|
|
929
|
-
test('exposes literal transparent ladders, solid palettes, and interaction state recipes', () => {
|
|
930
|
-
const model = generateThemeModel({ h: 230, s: 65, b: 85 });
|
|
931
|
-
expect(model.literalTransparentPrimitives.neutral.light['1000']).toMatch(/^hsla\(/);
|
|
932
|
-
expect(model.literalTransparentPrimitives.brand.dark['800']).toMatch(/^hsla\(/);
|
|
933
|
-
expect(model.literalTransparentPrimitives.system.info.light['50']).toMatch(/^hsla\(/);
|
|
934
|
-
expect(model.solidPrimitives.grey.light.roles.base).toBe('#ffffff');
|
|
935
|
-
expect(model.solidPrimitives.grey.dark.roles.overlay).toBe(model.tokens.dark['--dry-color-bg-overlay']);
|
|
936
|
-
expect(model.interactionStates.brand.light.focusRing).toBe(model.tokens.light['--dry-color-focus-ring']);
|
|
937
|
-
expect(model.interactionStates.system.warning.dark.disabledFill).toBe(model.tokens.dark['--dry-color-fill-warning-weak']);
|
|
938
|
-
});
|
|
939
|
-
test('emits a shared audit result for token contexts', () => {
|
|
940
|
-
const model = generateThemeModel({ h: 230, s: 65, b: 85 });
|
|
941
|
-
expect(model.audit.contextChecks.length).toBeGreaterThan(10);
|
|
942
|
-
expect(model.audit.allPass).toBe(true);
|
|
943
|
-
expect(model.audit.contextChecks.every((check) => check.passes)).toBe(true);
|
|
944
|
-
});
|
|
945
|
-
test('chooses the safest interaction hue from multiple brand candidates', () => {
|
|
946
|
-
const model = generateThemeModel({ h: 0, s: 80, b: 88 }, {
|
|
947
|
-
brandCandidates: [
|
|
948
|
-
{ h: 230, s: 65, b: 85 },
|
|
949
|
-
{ h: 160, s: 70, b: 80 }
|
|
950
|
-
]
|
|
951
|
-
});
|
|
952
|
-
expect(model.brandPolicy.multipleBrand).toBe(true);
|
|
953
|
-
expect(model.brandPolicy.interactive.id).toBe('accent-1');
|
|
954
|
-
expect(model.brandPolicy.fallbackTriggered).toBe(true);
|
|
955
|
-
});
|
|
956
|
-
});
|