@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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * @module tests/catalog
3
+ * @description Unit tests for the @soulcraft/theme theme catalog.
4
+ *
5
+ * Validates that all 23 predefined themes are present, structurally complete
6
+ * (all 15 color properties + fonts + meta), and use valid OKLCH color strings.
7
+ * Also tests catalog lookup, category filtering, and grouped access.
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import {
12
+ THEME_CATALOG,
13
+ CATALOG_BY_ID,
14
+ getThemesByCategory,
15
+ getThemesGrouped,
16
+ } from '../src/catalog.js';
17
+ import { parseOklch } from '../src/oklch.js';
18
+ import type { ThemeName } from '../src/types.js';
19
+
20
+ // All 23 theme IDs expected in the catalog
21
+ const ALL_THEME_IDS: ThemeName[] = [
22
+ 'soulcraft-dark',
23
+ 'soulcraft-light',
24
+ 'tokyo-night',
25
+ 'catppuccin-mocha',
26
+ 'gruvbox-dark',
27
+ 'material-theme-darker',
28
+ 'material-theme-palenight',
29
+ 'ayu-dark',
30
+ 'synthwave-84',
31
+ 'one-dark-pro',
32
+ 'dracula',
33
+ 'nord',
34
+ 'monokai',
35
+ 'night-owl',
36
+ 'solarized-dark',
37
+ 'github-dark',
38
+ 'github-dark-dimmed',
39
+ 'catppuccin-latte',
40
+ 'gruvbox-light',
41
+ 'solarized-light',
42
+ 'github-light',
43
+ 'one-light',
44
+ 'material-theme-lighter',
45
+ ];
46
+
47
+ const COLOR_KEYS = [
48
+ 'bgBase', 'bgSurface', 'bgElevated',
49
+ 'textPrimary', 'textSecondary',
50
+ 'primary', 'primaryLight', 'primaryDark',
51
+ 'accent', 'accentLight',
52
+ 'glass', 'glassBorder',
53
+ 'success', 'warning', 'error',
54
+ ] as const;
55
+
56
+ // ─── THEME_CATALOG ────────────────────────────────────────────────────────────
57
+
58
+ describe('THEME_CATALOG', () => {
59
+ it('contains exactly 23 themes', () => {
60
+ expect(THEME_CATALOG).toHaveLength(23);
61
+ });
62
+
63
+ it('contains all expected theme IDs', () => {
64
+ const catalogIds = new Set(THEME_CATALOG.map(t => t.meta.id));
65
+ for (const id of ALL_THEME_IDS) {
66
+ expect(catalogIds.has(id), `missing theme: ${id}`).toBe(true);
67
+ }
68
+ });
69
+
70
+ it('every theme has all 15 color properties as valid OKLCH', () => {
71
+ for (const theme of THEME_CATALOG) {
72
+ for (const key of COLOR_KEYS) {
73
+ const value = theme.colors[key];
74
+ expect(value, `${theme.meta.id}.colors.${key} is missing`).toBeTruthy();
75
+ expect(
76
+ parseOklch(value),
77
+ `${theme.meta.id}.colors.${key} is not valid OKLCH: "${value}"`
78
+ ).not.toBeNull();
79
+ }
80
+ }
81
+ });
82
+
83
+ it('every theme has non-empty font properties', () => {
84
+ for (const theme of THEME_CATALOG) {
85
+ expect(theme.fonts.displayFont, `${theme.meta.id} missing displayFont`).toBeTruthy();
86
+ expect(theme.fonts.bodyFont, `${theme.meta.id} missing bodyFont`).toBeTruthy();
87
+ }
88
+ });
89
+
90
+ it('every theme has complete metadata', () => {
91
+ for (const theme of THEME_CATALOG) {
92
+ expect(theme.meta.id).toBeTruthy();
93
+ expect(theme.meta.name).toBeTruthy();
94
+ expect(['soulcraft', 'dark', 'light']).toContain(theme.meta.category);
95
+ expect(typeof theme.meta.isDark).toBe('boolean');
96
+ }
97
+ });
98
+
99
+ it('soulcraft themes are marked isDark correctly', () => {
100
+ const dark = THEME_CATALOG.find(t => t.meta.id === 'soulcraft-dark')!;
101
+ const light = THEME_CATALOG.find(t => t.meta.id === 'soulcraft-light')!;
102
+ expect(dark.meta.isDark).toBe(true);
103
+ expect(light.meta.isDark).toBe(false);
104
+ });
105
+
106
+ it('all theme IDs are unique', () => {
107
+ const ids = THEME_CATALOG.map(t => t.meta.id);
108
+ expect(new Set(ids).size).toBe(ids.length);
109
+ });
110
+ });
111
+
112
+ // ─── CATALOG_BY_ID ───────────────────────────────────────────────────────────
113
+
114
+ describe('CATALOG_BY_ID', () => {
115
+ it('has 23 entries', () => {
116
+ expect(CATALOG_BY_ID.size).toBe(23);
117
+ });
118
+
119
+ it('retrieves soulcraft-dark by ID', () => {
120
+ const theme = CATALOG_BY_ID.get('soulcraft-dark');
121
+ expect(theme).toBeDefined();
122
+ expect(theme!.meta.id).toBe('soulcraft-dark');
123
+ expect(theme!.meta.isDark).toBe(true);
124
+ expect(theme!.meta.category).toBe('soulcraft');
125
+ });
126
+
127
+ it('retrieves catppuccin-latte (light theme) by ID', () => {
128
+ const theme = CATALOG_BY_ID.get('catppuccin-latte');
129
+ expect(theme).toBeDefined();
130
+ expect(theme!.meta.isDark).toBe(false);
131
+ expect(theme!.meta.category).toBe('light');
132
+ });
133
+
134
+ it('retrieves nord by ID and verifies it has colors', () => {
135
+ const theme = CATALOG_BY_ID.get('nord');
136
+ expect(theme).toBeDefined();
137
+ expect(parseOklch(theme!.colors.primary)).not.toBeNull();
138
+ });
139
+
140
+ it('returns undefined for an unknown ID', () => {
141
+ expect(CATALOG_BY_ID.get('nonexistent-theme')).toBeUndefined();
142
+ expect(CATALOG_BY_ID.get('')).toBeUndefined();
143
+ });
144
+
145
+ it('all 23 IDs are retrievable', () => {
146
+ for (const id of ALL_THEME_IDS) {
147
+ expect(CATALOG_BY_ID.get(id), `${id} not found in CATALOG_BY_ID`).toBeDefined();
148
+ }
149
+ });
150
+ });
151
+
152
+ // ─── getThemesByCategory ──────────────────────────────────────────────────────
153
+
154
+ describe('getThemesByCategory', () => {
155
+ it('returns only soulcraft themes for "soulcraft"', () => {
156
+ const themes = getThemesByCategory('soulcraft');
157
+ expect(themes.length).toBeGreaterThan(0);
158
+ expect(themes.every(t => t.meta.category === 'soulcraft')).toBe(true);
159
+ });
160
+
161
+ it('returns only dark themes for "dark"', () => {
162
+ const themes = getThemesByCategory('dark');
163
+ expect(themes.length).toBeGreaterThan(0);
164
+ expect(themes.every(t => t.meta.category === 'dark')).toBe(true);
165
+ });
166
+
167
+ it('returns only light themes for "light"', () => {
168
+ const themes = getThemesByCategory('light');
169
+ expect(themes.length).toBeGreaterThan(0);
170
+ expect(themes.every(t => t.meta.category === 'light')).toBe(true);
171
+ });
172
+
173
+ it('soulcraft has exactly 2 themes', () => {
174
+ expect(getThemesByCategory('soulcraft')).toHaveLength(2);
175
+ });
176
+ });
177
+
178
+ // ─── getThemesGrouped ─────────────────────────────────────────────────────────
179
+
180
+ describe('getThemesGrouped', () => {
181
+ it('returns all three group keys', () => {
182
+ const grouped = getThemesGrouped();
183
+ expect(grouped).toHaveProperty('soulcraft');
184
+ expect(grouped).toHaveProperty('dark');
185
+ expect(grouped).toHaveProperty('light');
186
+ });
187
+
188
+ it('group totals sum to 23 themes', () => {
189
+ const grouped = getThemesGrouped();
190
+ const total = grouped.soulcraft.length + grouped.dark.length + grouped.light.length;
191
+ expect(total).toBe(23);
192
+ });
193
+
194
+ it('groups match getThemesByCategory results', () => {
195
+ const grouped = getThemesGrouped();
196
+ const byCategory = getThemesByCategory('dark');
197
+ expect(grouped.dark.map(t => t.meta.id)).toEqual(byCategory.map(t => t.meta.id));
198
+ });
199
+ });
@@ -0,0 +1,250 @@
1
+ /**
2
+ * @module tests/css
3
+ * @description Unit tests for the CSS custom property builder functions.
4
+ *
5
+ * Validates that buildThemeCss() produces all 17 required CSS vars, that the
6
+ * static alias builders contain the expected Workshop/Venue variable names,
7
+ * and that buildFontUrl() produces valid Google Fonts URLs.
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import {
12
+ buildThemeCss,
13
+ buildWorkshopAliasCss,
14
+ buildBrandAliasCss,
15
+ buildWorkshopDerivedCss,
16
+ buildFontUrl,
17
+ buildExportStyles,
18
+ } from '../src/css.js';
19
+ import { CATALOG_BY_ID } from '../src/catalog.js';
20
+ import type { ThemeDefinition } from '../src/types.js';
21
+
22
+ const soulcraftDark = CATALOG_BY_ID.get('soulcraft-dark')!;
23
+
24
+ // ─── buildThemeCss ────────────────────────────────────────────────────────────
25
+
26
+ describe('buildThemeCss', () => {
27
+ const EXPECTED_VARS = [
28
+ '--theme-bg-base',
29
+ '--theme-bg-surface',
30
+ '--theme-bg-elevated',
31
+ '--theme-text-primary',
32
+ '--theme-text-secondary',
33
+ '--theme-primary',
34
+ '--theme-primary-light',
35
+ '--theme-primary-dark',
36
+ '--theme-accent',
37
+ '--theme-accent-light',
38
+ '--theme-glass',
39
+ '--theme-glass-border',
40
+ '--theme-success',
41
+ '--theme-warning',
42
+ '--theme-error',
43
+ '--theme-font-display',
44
+ '--theme-font-body',
45
+ ];
46
+
47
+ it('contains all 17 CSS custom properties', () => {
48
+ const css = buildThemeCss(soulcraftDark);
49
+ for (const v of EXPECTED_VARS) {
50
+ expect(css, `missing ${v}`).toContain(v);
51
+ }
52
+ });
53
+
54
+ it('embeds the theme color values directly', () => {
55
+ const css = buildThemeCss(soulcraftDark);
56
+ expect(css).toContain(soulcraftDark.colors.primary);
57
+ expect(css).toContain(soulcraftDark.colors.bgBase);
58
+ });
59
+
60
+ it('embeds font names', () => {
61
+ const css = buildThemeCss(soulcraftDark);
62
+ expect(css).toContain(soulcraftDark.fonts.displayFont);
63
+ expect(css).toContain(soulcraftDark.fonts.bodyFont);
64
+ });
65
+
66
+ it('splits into exactly 17 semicolon-separated declarations', () => {
67
+ const css = buildThemeCss(soulcraftDark);
68
+ const parts = css.split(';').filter(Boolean);
69
+ expect(parts).toHaveLength(17);
70
+ });
71
+
72
+ it('works for all 23 catalog themes without throwing', () => {
73
+ for (const theme of CATALOG_BY_ID.values()) {
74
+ expect(() => buildThemeCss(theme)).not.toThrow();
75
+ expect(buildThemeCss(theme)).toContain('--theme-primary');
76
+ }
77
+ });
78
+ });
79
+
80
+ // ─── buildWorkshopAliasCss ────────────────────────────────────────────────────
81
+
82
+ describe('buildWorkshopAliasCss', () => {
83
+ it('contains Workshop legacy core palette aliases', () => {
84
+ const css = buildWorkshopAliasCss();
85
+ const expected = [
86
+ '--bg-dark', '--bg-medium', '--bg-light',
87
+ '--text-primary', '--text-secondary',
88
+ '--primary', '--primary-light', '--primary-dark',
89
+ '--accent', '--accent-light',
90
+ '--glass', '--glass-border',
91
+ '--success', '--warning', '--error',
92
+ ];
93
+ for (const v of expected) {
94
+ expect(css, `missing ${v}`).toContain(v);
95
+ }
96
+ });
97
+
98
+ it('contains Workshop semantic surface aliases', () => {
99
+ const css = buildWorkshopAliasCss();
100
+ expect(css).toContain('--surface-primary');
101
+ expect(css).toContain('--surface-secondary');
102
+ expect(css).toContain('--border-subtle');
103
+ expect(css).toContain('--text-muted');
104
+ });
105
+
106
+ it('maps all aliases to --theme-* vars (not hardcoded values)', () => {
107
+ const css = buildWorkshopAliasCss();
108
+ expect(css).toContain('var(--theme-bg-base)');
109
+ expect(css).toContain('var(--theme-primary)');
110
+ expect(css).toContain('var(--theme-text-secondary)');
111
+ });
112
+
113
+ it('is a pure static string (no theme argument)', () => {
114
+ // Calling twice returns identical string
115
+ expect(buildWorkshopAliasCss()).toBe(buildWorkshopAliasCss());
116
+ });
117
+ });
118
+
119
+ // ─── buildBrandAliasCss ───────────────────────────────────────────────────────
120
+
121
+ describe('buildBrandAliasCss', () => {
122
+ it('contains all Venue brand variables', () => {
123
+ const css = buildBrandAliasCss();
124
+ expect(css).toContain('--brand-primary');
125
+ expect(css).toContain('--brand-background');
126
+ expect(css).toContain('--brand-accent');
127
+ expect(css).toContain('--brand-text');
128
+ expect(css).toContain('--brand-font-display');
129
+ expect(css).toContain('--brand-font-body');
130
+ });
131
+
132
+ it('maps to --theme-* vars', () => {
133
+ const css = buildBrandAliasCss();
134
+ expect(css).toContain('var(--theme-primary)');
135
+ expect(css).toContain('var(--theme-bg-base)');
136
+ expect(css).toContain('var(--theme-text-primary)');
137
+ });
138
+
139
+ it('is a pure static string', () => {
140
+ expect(buildBrandAliasCss()).toBe(buildBrandAliasCss());
141
+ });
142
+ });
143
+
144
+ // ─── buildWorkshopDerivedCss ──────────────────────────────────────────────────
145
+
146
+ describe('buildWorkshopDerivedCss', () => {
147
+ it('includes --accent-subtle with alpha', () => {
148
+ const css = buildWorkshopDerivedCss(soulcraftDark);
149
+ expect(css).toContain('--accent-subtle');
150
+ // The derived var should have an alpha modifier
151
+ expect(css).toContain(' / ');
152
+ });
153
+
154
+ it('includes --success-subtle with alpha', () => {
155
+ const css = buildWorkshopDerivedCss(soulcraftDark);
156
+ expect(css).toContain('--success-subtle');
157
+ });
158
+
159
+ it('works for all 23 themes without throwing', () => {
160
+ for (const theme of CATALOG_BY_ID.values()) {
161
+ expect(() => buildWorkshopDerivedCss(theme)).not.toThrow();
162
+ }
163
+ });
164
+ });
165
+
166
+ // ─── buildFontUrl ─────────────────────────────────────────────────────────────
167
+
168
+ describe('buildFontUrl', () => {
169
+ it('returns null when both fonts are system-ui', () => {
170
+ const theme: ThemeDefinition = {
171
+ ...soulcraftDark,
172
+ fonts: { displayFont: 'system-ui', bodyFont: 'system-ui' },
173
+ };
174
+ expect(buildFontUrl(theme)).toBeNull();
175
+ });
176
+
177
+ it('returns a Google Fonts URL for a named display font', () => {
178
+ const theme: ThemeDefinition = {
179
+ ...soulcraftDark,
180
+ fonts: { displayFont: 'Inter', bodyFont: 'system-ui' },
181
+ };
182
+ const url = buildFontUrl(theme);
183
+ expect(url).not.toBeNull();
184
+ expect(url).toContain('fonts.googleapis.com');
185
+ expect(url).toContain('Inter');
186
+ });
187
+
188
+ it('includes both fonts in the URL when both are named', () => {
189
+ const theme: ThemeDefinition = {
190
+ ...soulcraftDark,
191
+ fonts: { displayFont: 'Fraunces', bodyFont: 'Inter' },
192
+ };
193
+ const url = buildFontUrl(theme);
194
+ expect(url).not.toBeNull();
195
+ expect(url).toContain('Fraunces');
196
+ expect(url).toContain('Inter');
197
+ });
198
+
199
+ it('handles Fraunces with optical-size axes (special case)', () => {
200
+ const theme: ThemeDefinition = {
201
+ ...soulcraftDark,
202
+ fonts: { displayFont: 'Fraunces', bodyFont: 'system-ui' },
203
+ };
204
+ const url = buildFontUrl(theme);
205
+ expect(url).not.toBeNull();
206
+ // Fraunces needs optical-size axes in the URL
207
+ expect(url).toContain('Fraunces');
208
+ });
209
+
210
+ it('returns a valid URL format', () => {
211
+ const theme: ThemeDefinition = {
212
+ ...soulcraftDark,
213
+ fonts: { displayFont: 'DM Sans', bodyFont: 'system-ui' },
214
+ };
215
+ const url = buildFontUrl(theme);
216
+ if (url) {
217
+ expect(() => new URL(url)).not.toThrow();
218
+ }
219
+ });
220
+ });
221
+
222
+ // ─── buildExportStyles ────────────────────────────────────────────────────────
223
+
224
+ describe('buildExportStyles', () => {
225
+ it('produces a <style> block', () => {
226
+ const styles = buildExportStyles(soulcraftDark);
227
+ expect(styles).toContain('<style>');
228
+ expect(styles).toContain('</style>');
229
+ });
230
+
231
+ it('includes :root with color variable declarations', () => {
232
+ const styles = buildExportStyles(soulcraftDark);
233
+ expect(styles).toContain(':root');
234
+ // buildExportStyles uses legacy export var names (--primary, --bg, etc.)
235
+ expect(styles).toContain('--primary');
236
+ expect(styles).toContain('oklch(');
237
+ });
238
+
239
+ it('includes body styling for immediate visual feedback', () => {
240
+ const styles = buildExportStyles(soulcraftDark);
241
+ expect(styles).toContain('body');
242
+ expect(styles).toContain('background');
243
+ });
244
+
245
+ it('works for all 23 catalog themes without throwing', () => {
246
+ for (const theme of CATALOG_BY_ID.values()) {
247
+ expect(() => buildExportStyles(theme)).not.toThrow();
248
+ }
249
+ });
250
+ });