vue-multiple-themes 6.0.2 → 7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vue-multiple-themes",
3
- "version": "6.0.2",
3
+ "version": "7.0.0",
4
4
  "description": "Vue 3 multi-theme engine: CSS custom properties, TailwindCSS opacity modifiers, white-label brand contexts, composable API",
5
5
  "author": "Pooya Golchian <pooya.golchian@gmail.com>",
6
6
  "license": "MIT",
@@ -30,6 +30,25 @@
30
30
  "dist",
31
31
  "src"
32
32
  ],
33
+ "scripts": {
34
+ "dev": "pnpm --filter playground dev",
35
+ "build": "vite build",
36
+ "build:check": "vue-tsc --noEmit",
37
+ "build:playground": "pnpm --filter playground build",
38
+ "preview": "pnpm --filter playground preview",
39
+ "generate:logo": "node scripts/generate-logo.mjs",
40
+ "changeset": "changeset",
41
+ "version-packages": "changeset version",
42
+ "release": "pnpm build && changeset publish",
43
+ "docs:dev": "vitepress dev docs",
44
+ "docs:build": "vitepress build docs",
45
+ "docs:preview": "vitepress preview docs",
46
+ "prepublishOnly": "pnpm build",
47
+ "test": "vitest run",
48
+ "test:watch": "vitest",
49
+ "test:coverage": "vitest run --coverage",
50
+ "typecheck": "pnpm build:check"
51
+ },
33
52
  "keywords": [
34
53
  "vue",
35
54
  "vue3",
@@ -52,8 +71,20 @@
52
71
  "nuxt",
53
72
  "white-label",
54
73
  "brand-context",
55
- "namespace"
74
+ "namespace",
75
+ "accessibility",
76
+ "a11y",
77
+ "color-utilities",
78
+ "theme-generator",
79
+ "multi-tenant",
80
+ "micro-frontend",
81
+ "vite",
82
+ "zero-dependencies"
56
83
  ],
84
+ "funding": {
85
+ "type": "github",
86
+ "url": "https://github.com/sponsors/pooyagolchian"
87
+ },
57
88
  "homepage": "https://pooyagolchian.github.io/vue-multiple-themes",
58
89
  "repository": {
59
90
  "type": "git",
@@ -70,18 +101,21 @@
70
101
  "optional": false
71
102
  }
72
103
  },
73
- "dependencies": {},
74
104
  "devDependencies": {
75
105
  "@changesets/cli": "^2.29.8",
76
106
  "@types/node": "^20.14.0",
77
107
  "@vitejs/plugin-vue": "^6.0.0",
108
+ "@vue/test-utils": "^2.4.6",
78
109
  "autoprefixer": "^10.4.19",
110
+ "happy-dom": "^20.8.3",
79
111
  "postcss": "^8.4.38",
80
112
  "sharp": "^0.34.5",
81
113
  "tailwindcss": "^3.4.4",
82
114
  "typescript": "^5.7.0",
83
115
  "vite": "^6.0.0",
84
116
  "vite-plugin-dts": "^4.0.0",
117
+ "vitepress": "^1.6.4",
118
+ "vitest": "^4.0.18",
85
119
  "vue": "^3.5.0",
86
120
  "vue-tsc": "^2.1.0"
87
121
  },
@@ -89,16 +123,5 @@
89
123
  "node": ">=18",
90
124
  "pnpm": ">=9"
91
125
  },
92
- "scripts": {
93
- "dev": "pnpm --filter playground dev",
94
- "build": "vite build",
95
- "build:check": "vue-tsc --noEmit",
96
- "build:playground": "pnpm --filter playground build",
97
- "preview": "pnpm --filter playground preview",
98
- "generate:logo": "node scripts/generate-logo.mjs",
99
- "changeset": "changeset",
100
- "version-packages": "changeset version",
101
- "release": "pnpm build && changeset publish",
102
- "test": "pnpm build:check"
103
- }
126
+ "packageManager": "pnpm@9.4.0"
104
127
  }
package/readme.md CHANGED
@@ -7,7 +7,37 @@
7
7
  > Dynamic multi-theme support for **Vue 3** — CSS custom properties, TailwindCSS (with full opacity modifier support), WCAG contrast utilities, white-label brand contexts, and a reactive composable API.
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/vue-multiple-themes)](https://www.npmjs.com/package/vue-multiple-themes)
10
+ [![npm downloads](https://img.shields.io/npm/dm/vue-multiple-themes)](https://www.npmjs.com/package/vue-multiple-themes)
10
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
12
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
13
+ [![Vue 3](https://img.shields.io/badge/Vue-3.5+-4FC08D?logo=vuedotjs&logoColor=white)](https://vuejs.org/)
14
+ [![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-v3_%26_v4-38B2AC?logo=tailwindcss&logoColor=white)](https://tailwindcss.com/)
15
+ [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/vue-multiple-themes)
16
+ [![Tests](https://img.shields.io/badge/tests-116_passing-brightgreen)](https://github.com/pooyagolchian/vue-multiple-themes)
17
+
18
+ ---
19
+
20
+ ## Why vue-multiple-themes?
21
+
22
+ Most Vue theming solutions only handle light/dark toggling. **vue-multiple-themes** is a complete multi-theme engine for production applications.
23
+
24
+ ### Comparison with Alternatives
25
+
26
+ | Feature | vue-multiple-themes | @vueuse useColorMode | nuxt-color-mode |
27
+ |---|:---:|:---:|:---:|
28
+ | **Multiple themes (3+)** | ✅ Unlimited | ⚠️ Manual | ⚠️ Light/Dark only |
29
+ | **TailwindCSS v3 & v4 plugin** | ✅ Built-in | ❌ | ❌ |
30
+ | **Opacity modifiers** (`bg-primary/50`) | ✅ | ❌ | ❌ |
31
+ | **WCAG contrast utilities** | ✅ 5+ functions | ❌ | ❌ |
32
+ | **Theme generation from 1 color** | ✅ | ❌ | ❌ |
33
+ | **White-label / namespace** | ✅ `createBrandContext()` | ❌ | ❌ |
34
+ | **Color utility library** | ✅ 20+ functions | ❌ | ❌ |
35
+ | **Preset themes** | ✅ 7 included | ❌ | ❌ |
36
+ | **System preference** | ✅ | ✅ | ✅ |
37
+ | **TypeScript** | ✅ Full | ✅ | ✅ |
38
+ | **Zero dependencies** | ✅ | ❌ | ❌ |
39
+
40
+ > 📖 **[Full documentation →](https://pooyagolchian.github.io/vue-multiple-themes/)**
11
41
 
12
42
  ---
13
43
 
@@ -436,6 +466,19 @@ Full documentation and live demos:
436
466
 
437
467
  **<https://pooyagolchian.github.io/vue-multiple-themes/>**
438
468
 
469
+ - [Getting Started](https://pooyagolchian.github.io/vue-multiple-themes/guide/getting-started)
470
+ - [API Reference](https://pooyagolchian.github.io/vue-multiple-themes/api/)
471
+ - [TailwindCSS Integration](https://pooyagolchian.github.io/vue-multiple-themes/guide/tailwind)
472
+ - [White-Label / Multi-Tenant](https://pooyagolchian.github.io/vue-multiple-themes/guide/brand-context)
473
+ - [Theme Generation](https://pooyagolchian.github.io/vue-multiple-themes/guide/generation)
474
+ - [Color Utilities](https://pooyagolchian.github.io/vue-multiple-themes/guide/color-utils)
475
+ - [Nuxt / SSR](https://pooyagolchian.github.io/vue-multiple-themes/guide/nuxt-ssr)
476
+ - [Comparison with Alternatives](https://pooyagolchian.github.io/vue-multiple-themes/guide/comparison)
477
+
478
+ ### For AI / LLMs
479
+
480
+ This project includes an [`llms.txt`](llms.txt) file following the [llmstxt.org](https://llmstxt.org) specification, providing structured API documentation for AI assistants.
481
+
439
482
  ---
440
483
 
441
484
  ## License
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { createBrandContext } from './createBrandContext'
3
+ import { lightTheme, darkTheme } from '../themes/presets'
4
+ import type { ThemeDefinition } from '../types'
5
+
6
+ const testThemes: ThemeDefinition[] = [
7
+ {
8
+ name: 'brand-light',
9
+ label: 'Brand Light',
10
+ colors: {
11
+ primary: '#7c3aed',
12
+ background: '#ffffff',
13
+ text: '#111827',
14
+ surface: '#f8fafc',
15
+ border: '#e5e7eb',
16
+ },
17
+ },
18
+ {
19
+ name: 'brand-dark',
20
+ label: 'Brand Dark',
21
+ colors: {
22
+ primary: '#a78bfa',
23
+ background: '#0f172a',
24
+ text: '#f8fafc',
25
+ surface: '#1e293b',
26
+ border: '#334155',
27
+ },
28
+ },
29
+ ]
30
+
31
+ describe('createBrandContext', () => {
32
+ it('creates a context with correct namespace', () => {
33
+ const ctx = createBrandContext({
34
+ namespace: 'acme',
35
+ themes: testThemes,
36
+ defaultTheme: 'brand-light',
37
+ })
38
+ expect(ctx.namespace).toBe('acme')
39
+ })
40
+
41
+ it('exposes useTheme as a function', () => {
42
+ const ctx = createBrandContext({
43
+ namespace: 'beta',
44
+ themes: testThemes,
45
+ })
46
+ expect(typeof ctx.useTheme).toBe('function')
47
+ })
48
+
49
+ it('exposes BrandPlugin with install method', () => {
50
+ const ctx = createBrandContext({
51
+ namespace: 'gamma',
52
+ themes: testThemes,
53
+ })
54
+ expect(typeof ctx.BrandPlugin.install).toBe('function')
55
+ })
56
+
57
+ it('two contexts with different namespaces are independent', () => {
58
+ const ctx1 = createBrandContext({
59
+ namespace: 'brand-a',
60
+ themes: testThemes,
61
+ })
62
+ const ctx2 = createBrandContext({
63
+ namespace: 'brand-b',
64
+ themes: [lightTheme, darkTheme],
65
+ })
66
+ expect(ctx1.namespace).not.toBe(ctx2.namespace)
67
+ })
68
+ })
@@ -0,0 +1,395 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ hexToRgb,
4
+ rgbToHex,
5
+ rgbToHsl,
6
+ hslToRgb,
7
+ hexToHsl,
8
+ hslToHex,
9
+ parseColor,
10
+ lighten,
11
+ darken,
12
+ saturate,
13
+ rotateHue,
14
+ mix,
15
+ withAlpha,
16
+ luminance,
17
+ contrastRatio,
18
+ autoContrast,
19
+ ensureContrast,
20
+ generateColorScale,
21
+ complementary,
22
+ splitComplementary,
23
+ triadic,
24
+ analogous,
25
+ normalizeToRgbChannels,
26
+ } from './color'
27
+
28
+ // ─── Parsing / Conversion ─────────────────────────────────────────────────────
29
+
30
+ describe('hexToRgb', () => {
31
+ it('converts 6-digit hex', () => {
32
+ expect(hexToRgb('#3b82f6')).toEqual([59, 130, 246])
33
+ })
34
+
35
+ it('converts 3-digit shorthand hex', () => {
36
+ expect(hexToRgb('#fff')).toEqual([255, 255, 255])
37
+ })
38
+
39
+ it('handles black', () => {
40
+ expect(hexToRgb('#000000')).toEqual([0, 0, 0])
41
+ })
42
+
43
+ it('works without # prefix', () => {
44
+ expect(hexToRgb('ff0000')).toEqual([255, 0, 0])
45
+ })
46
+ })
47
+
48
+ describe('rgbToHex', () => {
49
+ it('converts RGB to hex', () => {
50
+ expect(rgbToHex(59, 130, 246)).toBe('#3b82f6')
51
+ })
52
+
53
+ it('converts black', () => {
54
+ expect(rgbToHex(0, 0, 0)).toBe('#000000')
55
+ })
56
+
57
+ it('converts white', () => {
58
+ expect(rgbToHex(255, 255, 255)).toBe('#ffffff')
59
+ })
60
+
61
+ it('clamps out-of-range values', () => {
62
+ expect(rgbToHex(300, -10, 128)).toBe('#ff0080')
63
+ })
64
+ })
65
+
66
+ describe('rgbToHsl', () => {
67
+ it('converts pure red', () => {
68
+ expect(rgbToHsl(255, 0, 0)).toEqual([0, 100, 50])
69
+ })
70
+
71
+ it('converts grey', () => {
72
+ const [h, s, l] = rgbToHsl(128, 128, 128)
73
+ expect(s).toBe(0)
74
+ expect(l).toBe(50)
75
+ })
76
+
77
+ it('converts white', () => {
78
+ expect(rgbToHsl(255, 255, 255)).toEqual([0, 0, 100])
79
+ })
80
+ })
81
+
82
+ describe('hslToRgb', () => {
83
+ it('converts achromatic (grey)', () => {
84
+ const [r, g, b] = hslToRgb(0, 0, 50)
85
+ expect(r).toBe(g)
86
+ expect(r).toBe(b)
87
+ expect(r).toBe(128)
88
+ })
89
+
90
+ it('converts pure red', () => {
91
+ expect(hslToRgb(0, 100, 50)).toEqual([255, 0, 0])
92
+ })
93
+
94
+ it('roundtrips with rgbToHsl', () => {
95
+ const original: [number, number, number] = [59, 130, 246]
96
+ const hsl = rgbToHsl(...original)
97
+ const back = hslToRgb(...hsl)
98
+ // Allow ±1 for rounding
99
+ for (let i = 0; i < 3; i++) {
100
+ expect(Math.abs(back[i] - original[i])).toBeLessThanOrEqual(1)
101
+ }
102
+ })
103
+ })
104
+
105
+ describe('hexToHsl / hslToHex roundtrip', () => {
106
+ it('roundtrips #3b82f6', () => {
107
+ const hsl = hexToHsl('#3b82f6')
108
+ const hex = hslToHex(...hsl)
109
+ const [r1, g1, b1] = hexToRgb('#3b82f6')
110
+ const [r2, g2, b2] = hexToRgb(hex)
111
+ expect(Math.abs(r1 - r2)).toBeLessThanOrEqual(1)
112
+ expect(Math.abs(g1 - g2)).toBeLessThanOrEqual(1)
113
+ expect(Math.abs(b1 - b2)).toBeLessThanOrEqual(1)
114
+ })
115
+ })
116
+
117
+ describe('parseColor', () => {
118
+ it('parses hex', () => {
119
+ const [h, s, l] = parseColor('#ff0000')
120
+ expect(h).toBe(0)
121
+ expect(s).toBe(100)
122
+ expect(l).toBe(50)
123
+ })
124
+
125
+ it('parses rgb()', () => {
126
+ const [h, s, l] = parseColor('rgb(255, 0, 0)')
127
+ expect(h).toBe(0)
128
+ expect(s).toBe(100)
129
+ expect(l).toBe(50)
130
+ })
131
+
132
+ it('parses hsl()', () => {
133
+ const hsl = parseColor('hsl(220, 90%, 56%)')
134
+ expect(hsl).toEqual([220, 90, 56])
135
+ })
136
+
137
+ it('returns fallback for unknown format', () => {
138
+ const hsl = parseColor('not-a-color')
139
+ expect(hsl).toEqual([220, 90, 56])
140
+ })
141
+ })
142
+
143
+ // ─── Manipulation ─────────────────────────────────────────────────────────────
144
+
145
+ describe('lighten', () => {
146
+ it('increases lightness', () => {
147
+ const result = lighten('#3b82f6', 20)
148
+ const [, , l] = hexToHsl(result)
149
+ const [, , originalL] = hexToHsl('#3b82f6')
150
+ expect(l).toBeGreaterThan(originalL)
151
+ })
152
+
153
+ it('does not exceed 100', () => {
154
+ const result = lighten('#ffffff', 50)
155
+ const [, , l] = hexToHsl(result)
156
+ expect(l).toBeLessThanOrEqual(100)
157
+ })
158
+ })
159
+
160
+ describe('darken', () => {
161
+ it('decreases lightness', () => {
162
+ const result = darken('#3b82f6', 20)
163
+ const [, , l] = hexToHsl(result)
164
+ const [, , originalL] = hexToHsl('#3b82f6')
165
+ expect(l).toBeLessThan(originalL)
166
+ })
167
+
168
+ it('does not go below 0', () => {
169
+ const result = darken('#000000', 50)
170
+ const [, , l] = hexToHsl(result)
171
+ expect(l).toBeGreaterThanOrEqual(0)
172
+ })
173
+ })
174
+
175
+ describe('saturate', () => {
176
+ it('increases saturation', () => {
177
+ const result = saturate('#808080', 20)
178
+ const [, s] = hexToHsl(result)
179
+ expect(s).toBeGreaterThan(0)
180
+ })
181
+
182
+ it('clamps to 100', () => {
183
+ const result = saturate('#ff0000', 200)
184
+ const [, s] = hexToHsl(result)
185
+ expect(s).toBeLessThanOrEqual(100)
186
+ })
187
+ })
188
+
189
+ describe('rotateHue', () => {
190
+ it('rotates hue by degrees', () => {
191
+ const complement = rotateHue('#ff0000', 180)
192
+ const [h] = hexToHsl(complement)
193
+ expect(h).toBe(180)
194
+ })
195
+
196
+ it('wraps around 360', () => {
197
+ const result = rotateHue('#ff0000', 400)
198
+ const [h] = hexToHsl(result)
199
+ expect(h).toBe(40)
200
+ })
201
+
202
+ it('handles negative degrees', () => {
203
+ const result = rotateHue('#ff0000', -90)
204
+ const [h] = hexToHsl(result)
205
+ expect(h).toBe(270)
206
+ })
207
+ })
208
+
209
+ describe('mix', () => {
210
+ it('mixes two colors at 50%', () => {
211
+ const result = mix('#000000', '#ffffff', 0.5)
212
+ const [r, g, b] = hexToRgb(result)
213
+ expect(r).toBeCloseTo(128, -1)
214
+ expect(g).toBeCloseTo(128, -1)
215
+ expect(b).toBeCloseTo(128, -1)
216
+ })
217
+
218
+ it('returns first color at weight 0', () => {
219
+ expect(mix('#ff0000', '#0000ff', 0)).toBe('#ff0000')
220
+ })
221
+
222
+ it('returns second color at weight 1', () => {
223
+ expect(mix('#ff0000', '#0000ff', 1)).toBe('#0000ff')
224
+ })
225
+ })
226
+
227
+ describe('withAlpha', () => {
228
+ it('returns rgba string', () => {
229
+ expect(withAlpha('#ff0000', 0.5)).toBe('rgba(255,0,0,0.5)')
230
+ })
231
+
232
+ it('handles full opacity', () => {
233
+ expect(withAlpha('#000000', 1)).toBe('rgba(0,0,0,1)')
234
+ })
235
+ })
236
+
237
+ // ─── Contrast & Accessibility ─────────────────────────────────────────────────
238
+
239
+ describe('luminance', () => {
240
+ it('returns 0 for black', () => {
241
+ expect(luminance('#000000')).toBeCloseTo(0, 4)
242
+ })
243
+
244
+ it('returns 1 for white', () => {
245
+ expect(luminance('#ffffff')).toBeCloseTo(1, 4)
246
+ })
247
+
248
+ it('returns mid-range for mid-grey', () => {
249
+ const lum = luminance('#808080')
250
+ expect(lum).toBeGreaterThan(0.1)
251
+ expect(lum).toBeLessThan(0.5)
252
+ })
253
+ })
254
+
255
+ describe('contrastRatio', () => {
256
+ it('max contrast is 21:1 for black vs white', () => {
257
+ expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 0)
258
+ })
259
+
260
+ it('same color has ratio 1:1', () => {
261
+ expect(contrastRatio('#3b82f6', '#3b82f6')).toBeCloseTo(1, 0)
262
+ })
263
+
264
+ it('is symmetric', () => {
265
+ const ab = contrastRatio('#3b82f6', '#ffffff')
266
+ const ba = contrastRatio('#ffffff', '#3b82f6')
267
+ expect(ab).toBeCloseTo(ba, 4)
268
+ })
269
+ })
270
+
271
+ describe('autoContrast', () => {
272
+ it('picks white text on dark background', () => {
273
+ expect(autoContrast('#0f172a')).toBe('#ffffff')
274
+ })
275
+
276
+ it('picks black text on light background', () => {
277
+ expect(autoContrast('#ffffff')).toBe('#000000')
278
+ })
279
+ })
280
+
281
+ describe('ensureContrast', () => {
282
+ it('adjusts text color until WCAG AA contrast is met', () => {
283
+ const adjusted = ensureContrast('#cccccc', '#ffffff', 4.5)
284
+ const ratio = contrastRatio(adjusted, '#ffffff')
285
+ expect(ratio).toBeGreaterThanOrEqual(4.5)
286
+ })
287
+
288
+ it('returns original if already passing', () => {
289
+ const result = ensureContrast('#000000', '#ffffff', 4.5)
290
+ expect(result).toBe('#000000')
291
+ })
292
+ })
293
+
294
+ // ─── Color Scale ──────────────────────────────────────────────────────────────
295
+
296
+ describe('generateColorScale', () => {
297
+ it('returns 11 stops', () => {
298
+ const scale = generateColorScale('#3b82f6')
299
+ const keys = Object.keys(scale)
300
+ expect(keys).toHaveLength(11)
301
+ expect(keys).toContain('50')
302
+ expect(keys).toContain('500')
303
+ expect(keys).toContain('950')
304
+ })
305
+
306
+ it('50 is lightest, 950 is darkest', () => {
307
+ const scale = generateColorScale('#3b82f6')
308
+ const l50 = hexToHsl(scale['50'])[2]
309
+ const l950 = hexToHsl(scale['950'])[2]
310
+ expect(l50).toBeGreaterThan(l950)
311
+ })
312
+ })
313
+
314
+ // ─── Harmony ──────────────────────────────────────────────────────────────────
315
+
316
+ describe('complementary', () => {
317
+ it('returns 180° opposite hue', () => {
318
+ const result = complementary('#ff0000')
319
+ const [h] = hexToHsl(result)
320
+ expect(h).toBe(180)
321
+ })
322
+ })
323
+
324
+ describe('splitComplementary', () => {
325
+ it('returns 3 colors', () => {
326
+ const result = splitComplementary('#ff0000')
327
+ expect(result).toHaveLength(3)
328
+ expect(result[0]).toBe('#ff0000')
329
+ })
330
+ })
331
+
332
+ describe('triadic', () => {
333
+ it('returns 3 colors at 120° intervals', () => {
334
+ const [, b, c] = triadic('#ff0000')
335
+ const hB = hexToHsl(b)[0]
336
+ const hC = hexToHsl(c)[0]
337
+ expect(hB).toBe(120)
338
+ expect(hC).toBe(240)
339
+ })
340
+ })
341
+
342
+ describe('analogous', () => {
343
+ it('returns 3 colors', () => {
344
+ const result = analogous('#ff0000')
345
+ expect(result).toHaveLength(3)
346
+ expect(result[0]).toBe('#ff0000')
347
+ })
348
+
349
+ it('respects custom angle', () => {
350
+ const [, b, c] = analogous('#ff0000', 45)
351
+ const hB = hexToHsl(b)[0]
352
+ const hC = hexToHsl(c)[0]
353
+ expect(hB).toBe(45)
354
+ expect(hC).toBe(315)
355
+ })
356
+ })
357
+
358
+ // ─── normalizeToRgbChannels ───────────────────────────────────────────────────
359
+
360
+ describe('normalizeToRgbChannels', () => {
361
+ it('normalizes hex to R G B', () => {
362
+ expect(normalizeToRgbChannels('#3b82f6')).toBe('59 130 246')
363
+ })
364
+
365
+ it('normalizes 3-digit hex', () => {
366
+ expect(normalizeToRgbChannels('#fff')).toBe('255 255 255')
367
+ })
368
+
369
+ it('passthrough channel format', () => {
370
+ expect(normalizeToRgbChannels('59 130 246')).toBe('59 130 246')
371
+ })
372
+
373
+ it('normalizes rgb()', () => {
374
+ expect(normalizeToRgbChannels('rgb(59, 130, 246)')).toBe('59 130 246')
375
+ })
376
+
377
+ it('normalizes rgba()', () => {
378
+ expect(normalizeToRgbChannels('rgba(59, 130, 246, 0.5)')).toBe('59 130 246')
379
+ })
380
+
381
+ it('normalizes hsl()', () => {
382
+ const result = normalizeToRgbChannels('hsl(0, 100%, 50%)')
383
+ expect(result).toBe('255 0 0')
384
+ })
385
+
386
+ it('normalizes named colors', () => {
387
+ expect(normalizeToRgbChannels('red')).toBe('255 0 0')
388
+ expect(normalizeToRgbChannels('white')).toBe('255 255 255')
389
+ expect(normalizeToRgbChannels('black')).toBe('0 0 0')
390
+ })
391
+
392
+ it('handles hex without # prefix', () => {
393
+ expect(normalizeToRgbChannels('ff0000')).toBe('255 0 0')
394
+ })
395
+ })
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { toKebab, buildCssVars, getStyleId, injectStyles, removeStyles } from './css-injector'
3
+ import { lightTheme, darkTheme } from '../themes/presets'
4
+
5
+ describe('toKebab', () => {
6
+ it('converts camelCase to kebab-case', () => {
7
+ expect(toKebab('primaryDark')).toBe('primary-dark')
8
+ })
9
+
10
+ it('handles single word', () => {
11
+ expect(toKebab('primary')).toBe('primary')
12
+ })
13
+
14
+ it('handles multiple capitals', () => {
15
+ expect(toKebab('surfaceElevated')).toBe('surface-elevated')
16
+ })
17
+
18
+ it('handles textInverse', () => {
19
+ expect(toKebab('textInverse')).toBe('text-inverse')
20
+ })
21
+ })
22
+
23
+ describe('getStyleId', () => {
24
+ it('returns default id without namespace', () => {
25
+ expect(getStyleId()).toBe('vmt-theme-styles')
26
+ })
27
+
28
+ it('returns namespaced id', () => {
29
+ expect(getStyleId('acme')).toBe('vmt-theme-styles-acme')
30
+ })
31
+ })
32
+
33
+ describe('buildCssVars', () => {
34
+ it('generates CSS with attribute selector', () => {
35
+ const css = buildCssVars([lightTheme, darkTheme], {
36
+ strategy: 'attribute',
37
+ attribute: 'data-theme',
38
+ classPrefix: 'theme-',
39
+ cssVarPrefix: '--vmt-',
40
+ target: 'html',
41
+ })
42
+ expect(css).toContain(':root[data-theme="light"]')
43
+ expect(css).toContain(':root[data-theme="dark"]')
44
+ expect(css).toContain('--vmt-primary:')
45
+ expect(css).toContain('--vmt-primary-color:')
46
+ })
47
+
48
+ it('generates CSS with class selector', () => {
49
+ const css = buildCssVars([lightTheme], {
50
+ strategy: 'class',
51
+ attribute: 'data-theme',
52
+ classPrefix: 'theme-',
53
+ cssVarPrefix: '--vmt-',
54
+ target: 'html',
55
+ })
56
+ expect(css).toContain(':root.theme-light')
57
+ })
58
+
59
+ it('generates CSS with both strategy', () => {
60
+ const css = buildCssVars([lightTheme], {
61
+ strategy: 'both',
62
+ attribute: 'data-theme',
63
+ classPrefix: 'theme-',
64
+ cssVarPrefix: '--vmt-',
65
+ target: 'html',
66
+ })
67
+ expect(css).toContain(':root[data-theme="light"]')
68
+ expect(css).toContain(':root.theme-light')
69
+ })
70
+
71
+ it('uses custom CSS var prefix', () => {
72
+ const css = buildCssVars([lightTheme], {
73
+ strategy: 'attribute',
74
+ attribute: 'data-theme',
75
+ classPrefix: 'theme-',
76
+ cssVarPrefix: '--brand-',
77
+ target: 'html',
78
+ })
79
+ expect(css).toContain('--brand-primary:')
80
+ })
81
+
82
+ it('includes RGB channels for Tailwind opacity modifiers', () => {
83
+ const css = buildCssVars([lightTheme], {
84
+ strategy: 'attribute',
85
+ attribute: 'data-theme',
86
+ classPrefix: 'theme-',
87
+ cssVarPrefix: '--vmt-',
88
+ target: 'html',
89
+ })
90
+ // Should have both channel format and rgb() format
91
+ expect(css).toContain('--vmt-primary:')
92
+ expect(css).toContain('--vmt-primary-color: rgb(')
93
+ })
94
+ })
95
+
96
+ describe('injectStyles / removeStyles', () => {
97
+ beforeEach(() => {
98
+ document.head.innerHTML = ''
99
+ })
100
+
101
+ it('injects a <style> tag', () => {
102
+ injectStyles('body { color: red }')
103
+ const el = document.getElementById('vmt-theme-styles')
104
+ expect(el).not.toBeNull()
105
+ expect(el?.textContent).toBe('body { color: red }')
106
+ })
107
+
108
+ it('updates existing <style> tag', () => {
109
+ injectStyles('a { color: blue }')
110
+ injectStyles('a { color: red }')
111
+ const els = document.querySelectorAll('#vmt-theme-styles')
112
+ expect(els).toHaveLength(1)
113
+ expect(els[0].textContent).toBe('a { color: red }')
114
+ })
115
+
116
+ it('uses namespaced id', () => {
117
+ injectStyles('body {}', 'acme')
118
+ expect(document.getElementById('vmt-theme-styles-acme')).not.toBeNull()
119
+ })
120
+
121
+ it('removeStyles removes the tag', () => {
122
+ injectStyles('body {}')
123
+ removeStyles()
124
+ expect(document.getElementById('vmt-theme-styles')).toBeNull()
125
+ })
126
+
127
+ it('removeStyles with namespace', () => {
128
+ injectStyles('body {}', 'acme')
129
+ removeStyles('acme')
130
+ expect(document.getElementById('vmt-theme-styles-acme')).toBeNull()
131
+ })
132
+ })
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { applyThemeToDom, clearThemeFromDom, readStorage, writeStorage, getSystemPreference } from './dom'
3
+
4
+ describe('applyThemeToDom', () => {
5
+ beforeEach(() => {
6
+ document.documentElement.removeAttribute('data-theme')
7
+ document.documentElement.className = ''
8
+ })
9
+
10
+ it('sets data-theme attribute with attribute strategy', () => {
11
+ applyThemeToDom('dark', null, { strategy: 'attribute' })
12
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
13
+ })
14
+
15
+ it('adds class with class strategy', () => {
16
+ applyThemeToDom('dark', null, { strategy: 'class' })
17
+ expect(document.documentElement.classList.contains('theme-dark')).toBe(true)
18
+ })
19
+
20
+ it('applies both strategies together', () => {
21
+ applyThemeToDom('dark', null, { strategy: 'both' })
22
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
23
+ expect(document.documentElement.classList.contains('theme-dark')).toBe(true)
24
+ })
25
+
26
+ it('removes old class when switching themes', () => {
27
+ applyThemeToDom('light', null, { strategy: 'class' })
28
+ applyThemeToDom('dark', 'light', { strategy: 'class' })
29
+ expect(document.documentElement.classList.contains('theme-dark')).toBe(true)
30
+ expect(document.documentElement.classList.contains('theme-light')).toBe(false)
31
+ })
32
+
33
+ it('uses custom attribute name', () => {
34
+ applyThemeToDom('dark', null, { strategy: 'attribute', attribute: 'data-color-scheme' })
35
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark')
36
+ })
37
+
38
+ it('uses custom class prefix', () => {
39
+ applyThemeToDom('dark', null, { strategy: 'class', classPrefix: 'color-' })
40
+ expect(document.documentElement.classList.contains('color-dark')).toBe(true)
41
+ })
42
+ })
43
+
44
+ describe('clearThemeFromDom', () => {
45
+ beforeEach(() => {
46
+ document.documentElement.removeAttribute('data-theme')
47
+ document.documentElement.className = ''
48
+ })
49
+
50
+ it('removes attribute', () => {
51
+ document.documentElement.setAttribute('data-theme', 'dark')
52
+ clearThemeFromDom('dark', { strategy: 'attribute' })
53
+ expect(document.documentElement.getAttribute('data-theme')).toBeNull()
54
+ })
55
+
56
+ it('removes class', () => {
57
+ document.documentElement.classList.add('theme-dark')
58
+ clearThemeFromDom('dark', { strategy: 'class' })
59
+ expect(document.documentElement.classList.contains('theme-dark')).toBe(false)
60
+ })
61
+ })
62
+
63
+ describe('readStorage / writeStorage', () => {
64
+ beforeEach(() => {
65
+ localStorage.clear()
66
+ sessionStorage.clear()
67
+ })
68
+
69
+ it('writes and reads from localStorage', () => {
70
+ writeStorage('vmt-test', 'dark', 'localStorage')
71
+ expect(readStorage('vmt-test', 'localStorage')).toBe('dark')
72
+ })
73
+
74
+ it('writes and reads from sessionStorage', () => {
75
+ writeStorage('vmt-test', 'light', 'sessionStorage')
76
+ expect(readStorage('vmt-test', 'sessionStorage')).toBe('light')
77
+ })
78
+
79
+ it('returns null for missing key', () => {
80
+ expect(readStorage('nonexistent', 'localStorage')).toBeNull()
81
+ })
82
+ })
83
+
84
+ describe('getSystemPreference', () => {
85
+ it('returns light or dark', () => {
86
+ const pref = getSystemPreference()
87
+ expect(['light', 'dark']).toContain(pref)
88
+ })
89
+ })
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ generateLightTheme,
4
+ generateDarkTheme,
5
+ generateThemePair,
6
+ generateThemeFromPalette,
7
+ generateSeasonalThemes,
8
+ buildCssMixTheme,
9
+ checkContrast,
10
+ } from './generate-theme'
11
+ import { contrastRatio } from './color'
12
+
13
+ describe('generateLightTheme', () => {
14
+ it('returns a theme with all required color tokens', () => {
15
+ const theme = generateLightTheme('#7c3aed')
16
+ expect(theme.name).toBe('generated-light')
17
+ expect(theme.label).toBe('Light')
18
+ expect(theme.colors.primary).toBeDefined()
19
+ expect(theme.colors.background).toBeDefined()
20
+ expect(theme.colors.text).toBeDefined()
21
+ expect(theme.colors.surface).toBeDefined()
22
+ expect(theme.colors.border).toBeDefined()
23
+ })
24
+
25
+ it('accepts custom name and label', () => {
26
+ const theme = generateLightTheme('#3b82f6', { name: 'brand', label: 'Brand Light' })
27
+ expect(theme.name).toBe('brand')
28
+ expect(theme.label).toBe('Brand Light')
29
+ })
30
+
31
+ it('generates a light background', () => {
32
+ const theme = generateLightTheme('#3b82f6')
33
+ // Background should be very light (high lightness hex)
34
+ const bg = theme.colors.background!
35
+ expect(bg).toMatch(/^#[f-f][0-9a-f]{5}$/i)
36
+ })
37
+
38
+ it('generates WCAG-compliant text on background', () => {
39
+ const theme = generateLightTheme('#3b82f6')
40
+ const ratio = contrastRatio(theme.colors.text!, theme.colors.background!)
41
+ expect(ratio).toBeGreaterThanOrEqual(4.5)
42
+ })
43
+
44
+ it('uses custom accent color when provided', () => {
45
+ const theme = generateLightTheme('#3b82f6', { accentColor: '#ff6600' })
46
+ expect(theme.colors.accent).toBe('#ff6600')
47
+ })
48
+
49
+ it('disables tinted surfaces when tintedSurfaces=false', () => {
50
+ const tinted = generateLightTheme('#ff0000', { tintedSurfaces: true })
51
+ const plain = generateLightTheme('#ff0000', { tintedSurfaces: false })
52
+ // Both should produce valid light themes
53
+ expect(tinted.colors.background).toBeDefined()
54
+ expect(plain.colors.background).toBeDefined()
55
+ })
56
+ })
57
+
58
+ describe('generateDarkTheme', () => {
59
+ it('returns a theme with all required color tokens', () => {
60
+ const theme = generateDarkTheme('#7c3aed')
61
+ expect(theme.name).toBe('generated-dark')
62
+ expect(theme.label).toBe('Dark')
63
+ expect(theme.colors.primary).toBeDefined()
64
+ expect(theme.colors.background).toBeDefined()
65
+ expect(theme.colors.text).toBeDefined()
66
+ })
67
+
68
+ it('generates a dark background', () => {
69
+ const theme = generateDarkTheme('#3b82f6')
70
+ // Background should be very dark (low R value hex)
71
+ const bg = theme.colors.background!
72
+ expect(bg).toMatch(/^#[0-3][0-9a-f]{5}$/i)
73
+ })
74
+
75
+ it('accepts custom name and label', () => {
76
+ const theme = generateDarkTheme('#3b82f6', { name: 'brand-dark', label: 'Brand Dark' })
77
+ expect(theme.name).toBe('brand-dark')
78
+ expect(theme.label).toBe('Brand Dark')
79
+ })
80
+ })
81
+
82
+ describe('generateThemePair', () => {
83
+ it('returns exactly two themes', () => {
84
+ const pair = generateThemePair('#7c3aed')
85
+ expect(pair).toHaveLength(2)
86
+ })
87
+
88
+ it('first is light, second is dark', () => {
89
+ const [light, dark] = generateThemePair('#7c3aed')
90
+ expect(light.name).toBe('light')
91
+ expect(dark.name).toBe('dark')
92
+ })
93
+
94
+ it('accepts custom names', () => {
95
+ const [light, dark] = generateThemePair('#7c3aed', {
96
+ lightName: 'brand-light',
97
+ darkName: 'brand-dark',
98
+ })
99
+ expect(light.name).toBe('brand-light')
100
+ expect(dark.name).toBe('brand-dark')
101
+ })
102
+ })
103
+
104
+ describe('generateThemeFromPalette', () => {
105
+ it('generates light variant with palette overrides', () => {
106
+ const theme = generateThemeFromPalette(
107
+ { primary: '#3b82f6', secondary: '#8b5cf6', accent: '#f59e0b' },
108
+ 'light',
109
+ )
110
+ expect(theme.colors.secondary).toBe('#8b5cf6')
111
+ expect(theme.colors.accent).toBe('#f59e0b')
112
+ })
113
+
114
+ it('generates dark variant', () => {
115
+ const theme = generateThemeFromPalette(
116
+ { primary: '#3b82f6' },
117
+ 'dark',
118
+ )
119
+ expect(theme.colors.primary).toBeDefined()
120
+ expect(theme.colors.background).toBeDefined()
121
+ })
122
+ })
123
+
124
+ describe('generateSeasonalThemes', () => {
125
+ const seasons = ['spring', 'summer', 'autumn', 'winter', 'midnight', 'neon', 'pastel'] as const
126
+
127
+ for (const season of seasons) {
128
+ it(`generates ${season} theme pair`, () => {
129
+ const [light, dark] = generateSeasonalThemes(season)
130
+ expect(light.name).toBe(`${season}-light`)
131
+ expect(dark.name).toBe(`${season}-dark`)
132
+ expect(light.colors.primary).toBeDefined()
133
+ expect(dark.colors.primary).toBeDefined()
134
+ })
135
+ }
136
+ })
137
+
138
+ describe('buildCssMixTheme', () => {
139
+ it('returns CSS string with selector', () => {
140
+ const css = buildCssMixTheme('#7c3aed', 'brand')
141
+ expect(css).toContain('[data-theme="brand"]')
142
+ expect(css).toContain('--vmt-primary')
143
+ expect(css).toContain('color-mix')
144
+ })
145
+
146
+ it('supports dark variant', () => {
147
+ const css = buildCssMixTheme('#7c3aed', 'brand', 'dark')
148
+ expect(css).toContain('[data-theme="brand"]')
149
+ expect(css).toContain('#000000')
150
+ })
151
+ })
152
+
153
+ describe('checkContrast', () => {
154
+ it('black on white passes all levels', () => {
155
+ const report = checkContrast('#000000', '#ffffff')
156
+ expect(report.aa).toBe(true)
157
+ expect(report.aaLarge).toBe(true)
158
+ expect(report.aaa).toBe(true)
159
+ expect(report.aaaLarge).toBe(true)
160
+ expect(report.ratio).toBeCloseTo(21, 0)
161
+ })
162
+
163
+ it('same color fails all levels', () => {
164
+ const report = checkContrast('#808080', '#808080')
165
+ expect(report.aa).toBe(false)
166
+ expect(report.aaLarge).toBe(false)
167
+ expect(report.ratio).toBeCloseTo(1, 0)
168
+ })
169
+
170
+ it('reports correct intermediate values', () => {
171
+ const report = checkContrast('#767676', '#ffffff')
172
+ expect(report.aaLarge).toBe(true)
173
+ expect(report.ratio).toBeGreaterThanOrEqual(4.5)
174
+ })
175
+ })