@soulcraft/theme 1.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 +60 -0
- package/src/catalog.ts +313 -0
- package/src/components/ColorInput.svelte +150 -0
- package/src/components/ColorInput.svelte.d.ts +14 -0
- package/src/components/FontSizeControl.svelte +138 -0
- package/src/components/FontSizeControl.svelte.d.ts +16 -0
- package/src/components/ThemeCustomizer.svelte +359 -0
- package/src/components/ThemeCustomizer.svelte.d.ts +14 -0
- package/src/components/ThemePicker.svelte +129 -0
- package/src/components/ThemePicker.svelte.d.ts +13 -0
- package/src/components/ThemeSwatch.svelte +136 -0
- package/src/components/ThemeSwatch.svelte.d.ts +14 -0
- package/src/css.ts +324 -0
- package/src/derive.ts +110 -0
- package/src/index.ts +63 -0
- package/src/oklch.ts +299 -0
- package/src/resolve.ts +103 -0
- package/src/stores/font-size.svelte.ts +158 -0
- package/src/stores/theme.svelte.ts +320 -0
- package/src/tailwind/tokens.css +57 -0
- package/src/types.ts +217 -0
- package/tests/catalog.test.ts +199 -0
- package/tests/css.test.ts +250 -0
- package/tests/oklch.test.ts +256 -0
- package/tests/resolve.test.ts +192 -0
- package/tsconfig.base.json +21 -0
- package/tsconfig.json +4 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tests/oklch
|
|
3
|
+
* @description Unit tests for OKLCH color space conversion utilities.
|
|
4
|
+
*
|
|
5
|
+
* Tests the full hex↔OKLCH round-trip conversion, parse/format fidelity,
|
|
6
|
+
* rgba conversion, and oklch arithmetic helpers (withAlpha, adjustL, rotateH).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
hexToOklch,
|
|
12
|
+
oklchToHex,
|
|
13
|
+
parseOklch,
|
|
14
|
+
formatOklch,
|
|
15
|
+
rgbaToOklch,
|
|
16
|
+
withAlpha,
|
|
17
|
+
adjustL,
|
|
18
|
+
rotateH,
|
|
19
|
+
} from '../src/oklch.js';
|
|
20
|
+
|
|
21
|
+
// ─── hexToOklch ──────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe('hexToOklch', () => {
|
|
24
|
+
it('converts a 6-digit hex to oklch format', () => {
|
|
25
|
+
const result = hexToOklch('#2a9d8f');
|
|
26
|
+
expect(result).toMatch(/^oklch\(/);
|
|
27
|
+
expect(result).toMatch(/\)$/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('produces parseable oklch for black', () => {
|
|
31
|
+
expect(parseOklch(hexToOklch('#000000'))).not.toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('produces white-like lightness for #ffffff', () => {
|
|
35
|
+
const parsed = parseOklch(hexToOklch('#ffffff'));
|
|
36
|
+
expect(parsed).not.toBeNull();
|
|
37
|
+
expect(parsed![0]).toBeCloseTo(1, 1);
|
|
38
|
+
expect(parsed![1]).toBeCloseTo(0, 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('expands 3-digit hex identically to 6-digit equivalent', () => {
|
|
42
|
+
expect(hexToOklch('#abc')).toBe(hexToOklch('#aabbcc'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('preserves alpha from 8-digit hex', () => {
|
|
46
|
+
const result = hexToOklch('#ffffff0d');
|
|
47
|
+
expect(result).toContain(' / ');
|
|
48
|
+
const parsed = parseOklch(result);
|
|
49
|
+
expect(parsed![3]).toBeCloseTo(0.051, 1); // 0x0d / 0xff ≈ 0.051
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ─── oklchToHex ──────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe('oklchToHex', () => {
|
|
56
|
+
it('round-trips #2a9d8f within ±1 per channel', () => {
|
|
57
|
+
const hex = '#2a9d8f';
|
|
58
|
+
const back = oklchToHex(hexToOklch(hex));
|
|
59
|
+
for (let i = 0; i < 3; i++) {
|
|
60
|
+
const offset = 1 + i * 2;
|
|
61
|
+
const original = parseInt(hex.slice(offset, offset + 2), 16);
|
|
62
|
+
const recovered = parseInt(back.slice(offset, offset + 2), 16);
|
|
63
|
+
expect(Math.abs(original - recovered)).toBeLessThanOrEqual(1);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('round-trips #0a0e1a (deep dark) within ±1 per channel', () => {
|
|
68
|
+
const hex = '#0a0e1a';
|
|
69
|
+
const back = oklchToHex(hexToOklch(hex));
|
|
70
|
+
for (let i = 0; i < 3; i++) {
|
|
71
|
+
const offset = 1 + i * 2;
|
|
72
|
+
const original = parseInt(hex.slice(offset, offset + 2), 16);
|
|
73
|
+
const recovered = parseInt(back.slice(offset, offset + 2), 16);
|
|
74
|
+
expect(Math.abs(original - recovered)).toBeLessThanOrEqual(1);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns #000000 for invalid input', () => {
|
|
79
|
+
expect(oklchToHex('not-a-color')).toBe('#000000');
|
|
80
|
+
expect(oklchToHex('')).toBe('#000000');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('encodes alpha as 8-digit hex when present', () => {
|
|
84
|
+
const result = oklchToHex('oklch(1 0 0 / 0.05)');
|
|
85
|
+
expect(result).toHaveLength(9); // '#rrggbbaa'
|
|
86
|
+
expect(result).toMatch(/^#[0-9a-f]{8}$/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns 6-digit hex when no alpha', () => {
|
|
90
|
+
const result = oklchToHex('oklch(0.624 0.082 181.4)');
|
|
91
|
+
expect(result).toHaveLength(7);
|
|
92
|
+
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─── parseOklch ──────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('parseOklch', () => {
|
|
99
|
+
it('parses a standard oklch string', () => {
|
|
100
|
+
const result = parseOklch('oklch(0.624 0.082 181.4)');
|
|
101
|
+
expect(result).not.toBeNull();
|
|
102
|
+
expect(result![0]).toBeCloseTo(0.624, 3);
|
|
103
|
+
expect(result![1]).toBeCloseTo(0.082, 3);
|
|
104
|
+
expect(result![2]).toBeCloseTo(181.4, 1);
|
|
105
|
+
expect(result![3]).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('parses oklch with alpha channel', () => {
|
|
109
|
+
const result = parseOklch('oklch(1 0 0 / 0.05)');
|
|
110
|
+
expect(result).not.toBeNull();
|
|
111
|
+
expect(result![0]).toBeCloseTo(1, 3);
|
|
112
|
+
expect(result![1]).toBeCloseTo(0, 3);
|
|
113
|
+
expect(result![3]).toBeCloseTo(0.05, 3);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns null for hex strings', () => {
|
|
117
|
+
expect(parseOklch('#2a9d8f')).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns null for rgb strings', () => {
|
|
121
|
+
expect(parseOklch('rgb(42, 157, 143)')).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('returns null for empty string', () => {
|
|
125
|
+
expect(parseOklch('')).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns null for malformed oklch', () => {
|
|
129
|
+
expect(parseOklch('oklch(0.5 0.1)')).toBeNull(); // missing H
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ─── formatOklch ─────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe('formatOklch', () => {
|
|
136
|
+
it('formats without alpha for opaque colors', () => {
|
|
137
|
+
expect(formatOklch(0.624, 0.082, 181.4)).toBe('oklch(0.6240 0.0820 181.4)');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('includes alpha when alpha < 1', () => {
|
|
141
|
+
const result = formatOklch(1, 0, 0, 0.05);
|
|
142
|
+
expect(result).toContain(' / 0.05');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('omits alpha when alpha is 1', () => {
|
|
146
|
+
const result = formatOklch(0.5, 0.1, 180, 1);
|
|
147
|
+
expect(result).not.toContain(' / ');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('produces valid parseable output', () => {
|
|
151
|
+
const formatted = formatOklch(0.5, 0.15, 270, 0.5);
|
|
152
|
+
expect(parseOklch(formatted)).not.toBeNull();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─── rgbaToOklch ─────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe('rgbaToOklch', () => {
|
|
159
|
+
it('converts rgba string preserving alpha', () => {
|
|
160
|
+
const result = rgbaToOklch('rgba(255, 255, 255, 0.05)');
|
|
161
|
+
expect(result).toMatch(/^oklch\(/);
|
|
162
|
+
const parsed = parseOklch(result);
|
|
163
|
+
expect(parsed![3]).toBeCloseTo(0.05, 2);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('converts rgb string without alpha', () => {
|
|
167
|
+
const result = rgbaToOklch('rgb(42, 157, 143)');
|
|
168
|
+
expect(result).toMatch(/^oklch\(/);
|
|
169
|
+
expect(result).not.toContain(' / ');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('returns white fallback for unparseable input', () => {
|
|
173
|
+
expect(rgbaToOklch('not-a-color')).toBe('oklch(1 0 0)');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ─── withAlpha ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe('withAlpha', () => {
|
|
180
|
+
it('adds alpha to an opaque color', () => {
|
|
181
|
+
const result = withAlpha('oklch(0.624 0.082 181.4)', 0.13);
|
|
182
|
+
expect(parseOklch(result)![3]).toBeCloseTo(0.13, 3);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('replaces existing alpha', () => {
|
|
186
|
+
const result = withAlpha('oklch(0.624 0.082 181.4 / 0.5)', 0.13);
|
|
187
|
+
expect(parseOklch(result)![3]).toBeCloseTo(0.13, 3);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('preserves L, C, H components', () => {
|
|
191
|
+
const result = withAlpha('oklch(0.624 0.082 181.4)', 0.5);
|
|
192
|
+
const parsed = parseOklch(result)!;
|
|
193
|
+
expect(parsed[0]).toBeCloseTo(0.624, 3);
|
|
194
|
+
expect(parsed[1]).toBeCloseTo(0.082, 3);
|
|
195
|
+
expect(parsed[2]).toBeCloseTo(181.4, 1);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ─── adjustL ─────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
describe('adjustL', () => {
|
|
202
|
+
it('increases lightness by delta', () => {
|
|
203
|
+
const result = adjustL('oklch(0.5 0.1 180)', 0.1);
|
|
204
|
+
expect(parseOklch(result)![0]).toBeCloseTo(0.6, 3);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('decreases lightness by negative delta', () => {
|
|
208
|
+
const result = adjustL('oklch(0.5 0.1 180)', -0.1);
|
|
209
|
+
expect(parseOklch(result)![0]).toBeCloseTo(0.4, 3);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('clamps lightness to 1 at the top', () => {
|
|
213
|
+
const result = adjustL('oklch(0.95 0.1 180)', 0.2);
|
|
214
|
+
expect(parseOklch(result)![0]).toBeLessThanOrEqual(1);
|
|
215
|
+
expect(parseOklch(result)![0]).toBeGreaterThanOrEqual(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('clamps lightness to 0 at the bottom', () => {
|
|
219
|
+
const result = adjustL('oklch(0.05 0.1 180)', -0.2);
|
|
220
|
+
expect(parseOklch(result)![0]).toBeGreaterThanOrEqual(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('preserves C and H components', () => {
|
|
224
|
+
const result = adjustL('oklch(0.5 0.12 200)', 0.1);
|
|
225
|
+
const parsed = parseOklch(result)!;
|
|
226
|
+
expect(parsed[1]).toBeCloseTo(0.12, 3);
|
|
227
|
+
expect(parsed[2]).toBeCloseTo(200, 1);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ─── rotateH ─────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
describe('rotateH', () => {
|
|
234
|
+
it('rotates hue forward by given degrees', () => {
|
|
235
|
+
const result = rotateH('oklch(0.624 0.082 181.4)', 150);
|
|
236
|
+
expect(parseOklch(result)![2]).toBeCloseTo(331.4, 1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('wraps hue forward past 360', () => {
|
|
240
|
+
const result = rotateH('oklch(0.624 0.082 350)', 30);
|
|
241
|
+
expect(parseOklch(result)![2]).toBeCloseTo(20, 1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('handles negative rotation (counter-clockwise)', () => {
|
|
245
|
+
const result = rotateH('oklch(0.624 0.082 10)', -30);
|
|
246
|
+
expect(parseOklch(result)![2]).toBeCloseTo(340, 1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('split-complementary rotation is 150 degrees', () => {
|
|
250
|
+
// Accent is derived from primary hue + 150 in deriveFullPalette
|
|
251
|
+
const primary = 'oklch(0.624 0.082 181.4)';
|
|
252
|
+
const accent = rotateH(primary, 150);
|
|
253
|
+
const diff = Math.abs(parseOklch(accent)![2] - parseOklch(primary)![2]);
|
|
254
|
+
expect(Math.min(diff, 360 - diff)).toBeCloseTo(150, 0);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tests/resolve
|
|
3
|
+
* @description Unit tests for the 4-level theme cascade resolver.
|
|
4
|
+
*
|
|
5
|
+
* Validates cascade priority (catalog → kit → custom → user), short-circuit
|
|
6
|
+
* behavior of userThemeId, ThemeSeed derivation, and override merging.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { resolveTheme } from '../src/resolve.js';
|
|
11
|
+
import { CATALOG_BY_ID } from '../src/catalog.js';
|
|
12
|
+
|
|
13
|
+
const COLOR_KEYS = [
|
|
14
|
+
'bgBase', 'bgSurface', 'bgElevated',
|
|
15
|
+
'textPrimary', 'textSecondary',
|
|
16
|
+
'primary', 'primaryLight', 'primaryDark',
|
|
17
|
+
'accent', 'accentLight',
|
|
18
|
+
'glass', 'glassBorder',
|
|
19
|
+
'success', 'warning', 'error',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe('resolveTheme — defaults', () => {
|
|
25
|
+
it('defaults to soulcraft-dark with an empty input', () => {
|
|
26
|
+
const theme = resolveTheme({});
|
|
27
|
+
expect(theme.meta.id).toBe('soulcraft-dark');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns a complete ThemeDefinition with all 15 color properties', () => {
|
|
31
|
+
const theme = resolveTheme({});
|
|
32
|
+
for (const key of COLOR_KEYS) {
|
|
33
|
+
expect(theme.colors[key], `missing: ${key}`).toBeTruthy();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns a theme with font properties', () => {
|
|
38
|
+
const theme = resolveTheme({});
|
|
39
|
+
expect(theme.fonts.displayFont).toBeTruthy();
|
|
40
|
+
expect(theme.fonts.bodyFont).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ─── Layer 1: Catalog theme ───────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe('resolveTheme — catalogId (layer 1)', () => {
|
|
47
|
+
it('resolves a named catalog theme', () => {
|
|
48
|
+
const theme = resolveTheme({ catalogId: 'tokyo-night' });
|
|
49
|
+
expect(theme.meta.id).toBe('tokyo-night');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns colors matching the catalog entry', () => {
|
|
53
|
+
const theme = resolveTheme({ catalogId: 'tokyo-night' });
|
|
54
|
+
const catalog = CATALOG_BY_ID.get('tokyo-night')!;
|
|
55
|
+
expect(theme.colors.primary).toBe(catalog.colors.primary);
|
|
56
|
+
expect(theme.colors.bgBase).toBe(catalog.colors.bgBase);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('resolves any catalogId and still returns all 15 color properties', () => {
|
|
60
|
+
// The resolver uses the catalogId as-is; unknown IDs fall back in catalog lookup
|
|
61
|
+
const theme = resolveTheme({ catalogId: 'tokyo-night' });
|
|
62
|
+
for (const key of COLOR_KEYS) {
|
|
63
|
+
expect(theme.colors[key]).toBeTruthy();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ─── Layer 2: Kit seed ────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe('resolveTheme — kitSeed (layer 2)', () => {
|
|
71
|
+
it('overrides primary from the kit seed', () => {
|
|
72
|
+
const kitPrimary = 'oklch(0.72 0.15 25)';
|
|
73
|
+
const theme = resolveTheme({
|
|
74
|
+
catalogId: 'soulcraft-dark',
|
|
75
|
+
kitSeed: { primary: kitPrimary, bgBase: 'oklch(0.05 0.02 261)' },
|
|
76
|
+
});
|
|
77
|
+
expect(theme.colors.primary).toBe(kitPrimary);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('overrides bgBase from the kit seed', () => {
|
|
81
|
+
const kitBgBase = 'oklch(0.96 0.02 80)';
|
|
82
|
+
const theme = resolveTheme({
|
|
83
|
+
kitSeed: { primary: 'oklch(0.72 0.15 25)', bgBase: kitBgBase },
|
|
84
|
+
});
|
|
85
|
+
expect(theme.colors.bgBase).toBe(kitBgBase);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('derives remaining colors from the seed (all 15 present)', () => {
|
|
89
|
+
const theme = resolveTheme({
|
|
90
|
+
kitSeed: { primary: 'oklch(0.72 0.15 25)', bgBase: 'oklch(0.96 0.02 80)' },
|
|
91
|
+
});
|
|
92
|
+
for (const key of COLOR_KEYS) {
|
|
93
|
+
expect(theme.colors[key], `kit-derived theme missing: ${key}`).toBeTruthy();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('uses explicit seed accent instead of deriving it', () => {
|
|
98
|
+
const explicitAccent = 'oklch(0.80 0.12 75)';
|
|
99
|
+
const theme = resolveTheme({
|
|
100
|
+
kitSeed: {
|
|
101
|
+
primary: 'oklch(0.72 0.15 25)',
|
|
102
|
+
bgBase: 'oklch(0.96 0.02 80)',
|
|
103
|
+
accent: explicitAccent,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
expect(theme.colors.accent).toBe(explicitAccent);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ─── Layer 3: Custom overrides ────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe('resolveTheme — customOverrides (layer 3)', () => {
|
|
113
|
+
it('overrides a specific color on top of catalog theme', () => {
|
|
114
|
+
const customPrimary = 'oklch(0.80 0.20 120)';
|
|
115
|
+
const theme = resolveTheme({
|
|
116
|
+
catalogId: 'soulcraft-dark',
|
|
117
|
+
customOverrides: { primary: customPrimary },
|
|
118
|
+
});
|
|
119
|
+
expect(theme.colors.primary).toBe(customPrimary);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('overrides font settings', () => {
|
|
123
|
+
const theme = resolveTheme({
|
|
124
|
+
catalogId: 'soulcraft-dark',
|
|
125
|
+
customOverrides: { displayFont: 'Fraunces', bodyFont: 'Inter' },
|
|
126
|
+
});
|
|
127
|
+
expect(theme.fonts.displayFont).toBe('Fraunces');
|
|
128
|
+
expect(theme.fonts.bodyFont).toBe('Inter');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not affect other colors beyond the override', () => {
|
|
132
|
+
const base = CATALOG_BY_ID.get('soulcraft-dark')!;
|
|
133
|
+
const theme = resolveTheme({
|
|
134
|
+
catalogId: 'soulcraft-dark',
|
|
135
|
+
customOverrides: { primary: 'oklch(0.80 0.20 120)' },
|
|
136
|
+
});
|
|
137
|
+
// bgBase should be unchanged from catalog
|
|
138
|
+
expect(theme.colors.bgBase).toBe(base.colors.bgBase);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('overrides win over kit seed colors', () => {
|
|
142
|
+
const seedPrimary = 'oklch(0.72 0.15 25)';
|
|
143
|
+
const overridePrimary = 'oklch(0.80 0.20 120)';
|
|
144
|
+
const theme = resolveTheme({
|
|
145
|
+
kitSeed: { primary: seedPrimary, bgBase: 'oklch(0.05 0.02 261)' },
|
|
146
|
+
customOverrides: { primary: overridePrimary },
|
|
147
|
+
});
|
|
148
|
+
expect(theme.colors.primary).toBe(overridePrimary);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ─── Layer 4: User preference ─────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe('resolveTheme — userThemeId (layer 4, short-circuit)', () => {
|
|
155
|
+
it('user preference overrides a kit seed', () => {
|
|
156
|
+
const theme = resolveTheme({
|
|
157
|
+
catalogId: 'soulcraft-dark',
|
|
158
|
+
kitSeed: { primary: 'oklch(0.72 0.15 25)', bgBase: 'oklch(0.96 0.02 80)' },
|
|
159
|
+
userThemeId: 'nord',
|
|
160
|
+
});
|
|
161
|
+
expect(theme.meta.id).toBe('nord');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('user preference overrides custom overrides', () => {
|
|
165
|
+
const theme = resolveTheme({
|
|
166
|
+
catalogId: 'soulcraft-dark',
|
|
167
|
+
customOverrides: { primary: 'oklch(0.80 0.20 120)' },
|
|
168
|
+
userThemeId: 'dracula',
|
|
169
|
+
});
|
|
170
|
+
expect(theme.meta.id).toBe('dracula');
|
|
171
|
+
const dracula = CATALOG_BY_ID.get('dracula')!;
|
|
172
|
+
expect(theme.colors.primary).toBe(dracula.colors.primary);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('user preference returns the full catalog theme unchanged', () => {
|
|
176
|
+
const theme = resolveTheme({
|
|
177
|
+
catalogId: 'soulcraft-dark',
|
|
178
|
+
userThemeId: 'monokai',
|
|
179
|
+
});
|
|
180
|
+
const monokai = CATALOG_BY_ID.get('monokai')!;
|
|
181
|
+
expect(theme.colors.bgBase).toBe(monokai.colors.bgBase);
|
|
182
|
+
expect(theme.meta.name).toBe(monokai.meta.name);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('invalid userThemeId falls through to the rest of the cascade', () => {
|
|
186
|
+
const theme = resolveTheme({
|
|
187
|
+
catalogId: 'tokyo-night',
|
|
188
|
+
userThemeId: 'this-theme-does-not-exist',
|
|
189
|
+
});
|
|
190
|
+
expect(theme.meta.id).toBe('tokyo-night');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"strict": true,
|
|
5
|
+
"noUncheckedIndexedAccess": true,
|
|
6
|
+
"noImplicitOverride": true,
|
|
7
|
+
"noFallthroughCasesInSwitch": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"module": "ESNext",
|
|
11
|
+
"target": "ES2024",
|
|
12
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
13
|
+
"customConditions": ["svelte"],
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"allowImportingTsExtensions": true,
|
|
18
|
+
"skipLibCheck": true,
|
|
19
|
+
"resolveJsonModule": true
|
|
20
|
+
}
|
|
21
|
+
}
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module vitest.config
|
|
3
|
+
* @description Vitest configuration for @soulcraft/theme unit tests.
|
|
4
|
+
* Tests cover oklch conversion accuracy, catalog completeness, cascade resolution,
|
|
5
|
+
* and CSS output correctness.
|
|
6
|
+
*/
|
|
7
|
+
import { defineConfig } from 'vitest/config';
|
|
8
|
+
|
|
9
|
+
export default defineConfig({
|
|
10
|
+
test: {
|
|
11
|
+
include: ['tests/**/*.test.ts'],
|
|
12
|
+
environment: 'node'
|
|
13
|
+
}
|
|
14
|
+
});
|