1import { describe, expect, it, vi } from "vite-plus/test";
2import { converter } from "culori/fn";
3import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
4
5import {
6 applyThemeColorPreview,
7 applyThemePalette,
8 getThemeColorsForMode,
9 getThemeDefinition,
10 getThemeModes,
11 getThemePreviewSidebarArtwork,
12 getThemePreferenceMode,
13 isKnownThemePreference,
14 getCustomThemes,
15 getStandardThemeColors,
16 getStoredCustomThemeCollection,
17 invalidateCustomThemes,
18 installCustomTheme,
19 canonicalThemePreference,
20 parseThemeFile,
21 parseThemeHalves,
22 removeCustomTheme,
23 removeCustomThemes,
24 replaceCustomThemeCollection,
25 resolveDesktopTheme,
26 resolveThemeAppearance,
27 serializeThemeFile,
28 subscribeToThemePreview,
29 subscribeToCustomThemes,
30 themeAllowsSidebarArtwork,
31 T3_CHAT_THEME,
32 EMBER_THEME,
33 GROVE_THEME,
34 IRIS_THEME,
35 OCEAN_THEME,
36 updateCustomTheme,
37 updateThemeColorFamily,
38 CUSTOM_THEMES_STORAGE_KEY,
39 createVividThemeColors,
40 getDefaultThemeColors,
41 themeColorToHex,
42 toCanonicalThemeColor,
43 THEME_FILE_VERSION,
44 singleAppearanceOf,
45} from "./themePalette";
46
47function asHex(value: string): string {
48 const hex = themeColorToHex(value);
49 if (!hex) throw new Error(`Expected a theme color, received ${value}`);
50 return hex.slice(0, 7);
51}
52
53function canonical(value: string): string {
54 const color = toCanonicalThemeColor(value);
55 if (!color) throw new Error(`Expected a theme color, received ${value}`);
56 return color;
57}
58
59function expectThemeColors(
60 colors: Readonly<Record<string, string>>,
61 expected: Readonly<Record<string, string>>,
62): void {
63 for (const [role, value] of Object.entries(expected)) {
64 expect(asHex(colors[role]!)).toBe(value);
65 }
66}
67
68function contrastRatio(first: string, second: string): number {
69 const toRgb = (value: string) => {
70 const hex = asHex(value).slice(1);
71 return [0, 1, 2].map(
72 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
73 );
74 };
75 const luminance = (value: string) =>
76 toRgb(value)
77 .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
78 .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0);
79 const lighter = Math.max(luminance(first), luminance(second));
80 const darker = Math.min(luminance(first), luminance(second));
81 return (lighter + 0.05) / (darker + 0.05);
82}
83
84describe("theme files", () => {
85 it("keeps every built-in palette value in canonical OKLCH form", () => {
86 for (const theme of BUILT_IN_THEMES) {
87 for (const colors of [theme.colors, ...Object.values(theme.variants ?? {})]) {
88 for (const value of Object.values(colors)) {
89 expect(toCanonicalThemeColor(value)).toBe(value);
90 }
91 }
92 }
93 });
94
95 it("keeps built-in and standard search pairs readable and distinct from inline code", () => {
96 const toOklab = converter("oklab");
97 const distance = (first: string, second: string) => {
98 const a = toOklab(first)!;
99 const b = toOklab(second)!;
100 return Math.hypot(a.l - b.l, a.a - b.a, a.b - b.b);
101 };
102 const palettes = [
103 getStandardThemeColors("light"),
104 getStandardThemeColors("dark"),
105 ...BUILT_IN_THEMES.flatMap((theme) => [theme.colors, ...Object.values(theme.variants ?? {})]),
106 ];
107 for (const colors of palettes) {
108 expect(
109 contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground),
110 ).toBeGreaterThanOrEqual(4.5);
111 expect(
112 contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground),
113 ).toBeGreaterThanOrEqual(4.5);
114 expect(
115 distance(colors.searchMatchBackground, colors.searchMatchActiveBackground),
116 ).toBeGreaterThan(0.14);
117 // Check inline-code tints, code blocks, and user-message bubbles.
118 for (const background of [colors.muted, colors.messageSurface, colors.codeBackground]) {
119 expect(distance(colors.searchMatchBackground, background)).toBeGreaterThan(0.08);
120 expect(distance(colors.searchMatchActiveBackground, background)).toBeGreaterThan(0.08);
121 }
122 }
123 });
124
125 it("round-trips custom search colours and fills them in for older theme files", () => {
126 const legacy = parseThemeFile({
127 version: THEME_FILE_VERSION,
128 name: "Legacy",
129 appearance: "dark",
130 colors: { canvas: "#101010" },
131 });
132 expect(legacy.colors.searchMatchBackground).toBe(
133 getDefaultThemeColors("dark").searchMatchBackground,
134 );
135 const theme = parseThemeFile({
136 version: THEME_FILE_VERSION,
137 name: "Search",
138 appearance: "dark",
139 colors: {
140 searchMatchBackground: "#224466",
141 searchMatchForeground: "#ffffff",
142 searchMatchActiveBackground: "#aaddff",
143 searchMatchActiveForeground: "#112233",
144 },
145 variants: { light: { searchMatchBackground: "#ddeeff", searchMatchForeground: "#112233" } },
146 });
147 const restored = parseThemeFile(JSON.parse(serializeThemeFile(theme)));
148 expect(restored.colors).toEqual(theme.colors);
149 expect(restored.variants).toEqual(theme.variants);
150 });
151
152 it.each(["searchMatchBackground", "searchMatchActiveBackground"] as const)(
153 "editing %s derives readable text without changing the other match or warning colours",
154 (role) => {
155 const original = getDefaultThemeColors("dark");
156 const foreground =
157 role === "searchMatchBackground" ? "searchMatchForeground" : "searchMatchActiveForeground";
158 for (const value of ["#ffffff", "#000000", "#33669980"]) {
159 const updated = updateThemeColorFamily("dark", original, role, value);
160 expect(contrastRatio(updated[foreground], updated[role])).toBeGreaterThanOrEqual(4.5);
161 for (const key of Object.keys(original) as Array<keyof typeof original>) {
162 if (key !== role && key !== foreground) expect(updated[key]).toBe(original[key]);
163 }
164 }
165 },
166 );
167
168 it("keeps stock dark controls in the neutral-black surface hierarchy", () => {
169 expectThemeColors(getStandardThemeColors("dark"), {
170 canvas: "#0a0a0a",
171 surface: "#111111",
172 surfaceRaised: "#111111",
173 surfaceOverlay: "#111111",
174 toolbarControl: "#111111",
175 secondary: "#111111",
176 muted: "#111111",
177 accentSurface: "#141414",
178 });
179 });
180
181 it("keeps the stock sidebar and chat on distinct surfaces in both appearances", () => {
182 expectThemeColors(getStandardThemeColors("light"), {
183 canvas: "#fcfcfc",
184 sidebar: "#fafafa",
185 sidebarRowActive: "#ffffff",
186 messageSurface: "#f4f4f5",
187 });
188 expectThemeColors(getStandardThemeColors("dark"), {
189 canvas: "#0a0a0a",
190 sidebar: "#000000",
191 sidebarRowActive: "#1a1b1b",
192 messageSurface: "#141414",
193 });
194 });
195
196 it("derives readable, distinctive vivid palettes from exact seeds", () => {
197 const seeds: ReadonlyArray<["light" | "dark", string, string]> = [
198 ["light", "#f4f9f2", "#1d8a4e"],
199 ["dark", "#101a2c", "#4f8fe8"],
200 ["dark", "#211a23", "#df5398"],
201 ["light", "#fdf6ec", "#c2571b"],
202 // Inverted canvases: the palette follows the picked color, not the slot.
203 ["light", "#111827", "#8ab4f8"],
204 ["dark", "#f5ecf5", "#a84370"],
205 ];
206 const channels = (value: string) =>
207 [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [
208 number,
209 number,
210 number,
211 ];
212 for (const [appearance, canvas, accent] of seeds) {
213 const colors = createVividThemeColors(appearance, canvas, accent);
214 // Exact seeds are honored.
215 expect(colors.canvas).toMatch(/^oklch\(/);
216 expect(colors.accent).toMatch(/^oklch\(/);
217 expect(asHex(colors.canvas)).toBe(canvas);
218 expect(asHex(colors.accent)).toBe(accent);
219 // Readability is solved per surface.
220 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7);
221 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5);
222 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5);
223 expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5);
224 expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeLessThan(
225 contrastRatio(colors.text, colors.muted),
226 );
227 expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5);
228 expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan(
229 contrastRatio(colors.text, colors.surfaceRaised),
230 );
231 expect(colors.secondaryLabel).toBe(colors.textMuted);
232 expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5);
233 expect(
234 contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground),
235 ).toBeGreaterThanOrEqual(4.5);
236 expect(
237 contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground),
238 ).toBeGreaterThanOrEqual(4.5);
239 expect(
240 contrastRatio(colors.messageActionForeground, colors.messageAction),
241 ).toBeGreaterThanOrEqual(4.5);
242 expect(contrastRatio(colors.messageForeground, colors.messageSurface)).toBeGreaterThanOrEqual(
243 4.5,
244 );
245 expect(contrastRatio(colors.secondaryForeground, colors.secondary)).toBeGreaterThanOrEqual(
246 4.5,
247 );
248 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
249 // The companion action is a distinct voice, not the accent again.
250 expect(colors.messageAction).not.toBe(colors.accent);
251 // Update family follows the theme, not the default palette.
252 expect(asHex(colors.update)).toBe(accent);
253 // Semantic statuses stay red and amber instead of inheriting a brand tint.
254 const [errorRed, errorGreen, errorBlue] = channels(colors.error);
255 expect(errorRed).toBeGreaterThan(errorGreen * 2);
256 expect(errorRed).toBeGreaterThan(errorBlue * 2);
257 expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5);
258 expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual(
259 4.5,
260 );
261 const [warnRed, warnGreen, warnBlue] = channels(colors.warning);
262 expect(warnRed).toBeGreaterThan(warnBlue);
263 expect(warnGreen).toBeGreaterThan(warnBlue);
264 }
265 });
266
267 it("keys status colors off the canvas, not the appearance slot", () => {
268 // Inverted seeds: a dark canvas in the light slot must still get the dark
269 // status pair, or the alert foreground lands on a dark surface unreadable.
270 const inverted = [
271 createVividThemeColors("light", "#111827", "#8ab4f8"),
272 createVividThemeColors("dark", "#f5ecf5", "#a84370"),
273 createVividThemeColors("light", "#0d1117", "#69b1ff"),
274 createVividThemeColors("dark", "#fdfdfd", "#c2571b"),
275 ];
276 for (const colors of inverted) {
277 expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual(
278 4.5,
279 );
280 expect(contrastRatio(colors.warningForeground, colors.warningSurface)).toBeGreaterThanOrEqual(
281 4.5,
282 );
283 }
284 });
285
286 it("merges a small user file onto the matching contrast-safe base palette", () => {
287 const theme = parseThemeFile({
288 version: THEME_FILE_VERSION,
289 name: "Ocean dusk",
290 appearance: "dark",
291 colors: {
292 canvas: "#07152f",
293 accent: "#67c2ff",
294 },
295 });
296
297 expect(theme).toMatchObject({
298 id: "ocean-dusk",
299 label: "Ocean dusk",
300 appearance: "dark",
301 colors: {
302 canvas: canonical("#07152f"),
303 accent: canonical("#67c2ff"),
304 placeholder: canonical("#968d9f"),
305 },
306 });
307 });
308
309 it("decodes literal CSS color formats into OKLCH without dropping alpha", () => {
310 const theme = parseThemeFile({
311 version: THEME_FILE_VERSION,
312 name: "Translucent",
313 appearance: "light",
314 colors: {
315 canvas: "oklch(62% 0.2 280deg / 50%)",
316 accent: "#abcd",
317 focus: "rgb(10 20 30 / 50%)",
318 error: "hsl(350 80% 50%)",
319 warning: "hwb(45 10% 20%)",
320 update: "lab(60% 40 30)",
321 messageAction: "lch(60% 50 120)",
322 sidebar: "oklab(0.6 0.1 -0.1)",
323 terminalCursor: "color(display-p3 0.8 0.2 0.3)",
324 terminalSelection: "rebeccapurple",
325 terminalScrollbar: "transparent",
326 terminalScrollbarHover: "rgb(10 20 30 / none)",
327 },
328 });
329
330 expect(theme.colors.canvas).toBe("oklch(0.62 0.2 280 / 0.5)");
331 expect(theme.colors.accent).toBe(canonical("#abcd"));
332 expect(themeColorToHex(theme.colors.accent)).toBe("#aabbccdd");
333 expect(themeColorToHex(theme.colors.focus)).toBe("#0a141e80");
334 expect(themeColorToHex(theme.colors.terminalSelection)).toBe("#663399");
335 expect(theme.colors.terminalScrollbar).toBe("oklch(0 0 0 / 0)");
336 expect(themeColorToHex(theme.colors.terminalScrollbarHover)).toBe("#0a141e00");
337 for (const role of [
338 "error",
339 "warning",
340 "update",
341 "messageAction",
342 "sidebar",
343 "terminalCursor",
344 ] as const) {
345 expect(theme.colors[role]).toMatch(/^oklch\(/);
346 }
347 });
348
349 it("gamut maps extreme finite OKLCH chroma from theme files", () => {
350 const theme = parseThemeFile({
351 version: THEME_FILE_VERSION,
352 name: "Extreme chroma",
353 appearance: "light",
354 colors: { accent: "oklch(0.5 1e303 0)" },
355 });
356
357 expect(theme.colors.accent).toBe("oklch(0.5 1e+303 0)");
358 expect(themeColorToHex(theme.colors.accent)).toBe("#b5005e");
359 });
360
361 it("rejects unknown roles and invalid color values", () => {
362 expect(() =>
363 parseThemeFile({
364 version: THEME_FILE_VERSION,
365 name: "Broken",
366 appearance: "light",
367 colors: { background: "#ffffff" },
368 }),
369 ).toThrow('"background" is not a supported theme color role.');
370
371 expect(() =>
372 parseThemeFile({
373 version: THEME_FILE_VERSION,
374 name: "Broken",
375 appearance: "light",
376 colors: { accent: "var(--danger)" },
377 }),
378 ).toThrow('The color for "accent" must be a literal CSS color');
379 });
380
381 it("canonicalizes the explicitly exported theme", () => {
382 const serialized = serializeThemeFile({
383 ...T3_CHAT_THEME,
384 colors: { ...T3_CHAT_THEME.colors, accent: "hsl(263 70% 58%)" },
385 });
386 expect(JSON.parse(serialized)).toMatchObject({
387 version: THEME_FILE_VERSION,
388 id: T3_CHAT_THEME.id,
389 name: T3_CHAT_THEME.label,
390 appearance: "light",
391 colors: { accent: canonical("hsl(263 70% 58%)") },
392 });
393 });
394
395 it("serializes a theme back into the importable file shape", () => {
396 const theme = {
397 ...parseThemeFile({
398 version: THEME_FILE_VERSION,
399 id: "community-demo",
400 name: "Community Demo",
401 appearance: "dark",
402 colors: { canvas: "#111111" },
403 }),
404 collection: { id: "open-vsx:demo.theme", label: "Demo Theme" },
405 };
406 const serialized = serializeThemeFile(theme);
407 expect(JSON.parse(serialized)).toMatchObject({
408 version: THEME_FILE_VERSION,
409 id: theme.id,
410 name: theme.label,
411 appearance: "dark",
412 collection: theme.collection,
413 });
414 expect(parseThemeFile(JSON.parse(serialized)).collection).toEqual(theme.collection);
415 });
416
417 it("keeps sidebar artwork disabled for custom theme files", () => {
418 const theme = parseThemeFile({
419 version: THEME_FILE_VERSION,
420 name: "Art sidebar",
421 appearance: "light",
422 colors: { accent: "#5b6cff" },
423 sidebarArtwork: true,
424 });
425
426 expect(theme.sidebarArtwork).toBeUndefined();
427 expect(JSON.parse(serializeThemeFile(theme))).not.toHaveProperty("sidebarArtwork");
428 });
429
430 it("suppresses sidebar artwork during a live custom-theme preview", () => {
431 const listener = vi.fn();
432 const unsubscribe = subscribeToThemePreview(listener);
433 vi.stubGlobal("document", {
434 documentElement: {
435 classList: { toggle: vi.fn() },
436 dataset: {},
437 style: { removeProperty: vi.fn(), setProperty: vi.fn() },
438 },
439 });
440
441 applyThemeColorPreview(T3_CHAT_THEME.colors, "light");
442 expect(getThemePreviewSidebarArtwork()).toBe(false);
443 expect(listener).toHaveBeenCalledTimes(1);
444
445 applyThemePalette("system");
446 expect(getThemePreviewSidebarArtwork()).toBeNull();
447 expect(listener).toHaveBeenCalledTimes(2);
448
449 unsubscribe();
450 vi.unstubAllGlobals();
451 });
452
453 it("keeps optional light and dark palettes under one theme id", () => {
454 const theme = parseThemeFile({
455 version: THEME_FILE_VERSION,
456 id: "aurora",
457 name: "Aurora",
458 appearance: "light",
459 colors: { canvas: "#f8fbff", text: "#10243d" },
460 variants: {
461 dark: { canvas: "#101827", text: "#eef5ff" },
462 },
463 });
464
465 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
466 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({
467 canvas: canonical("#101827"),
468 text: canonical("#eef5ff"),
469 });
470 expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]);
471 expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark");
472 expect(resolveDesktopTheme(T3_CHAT_THEME.id, true)).toBe("system");
473 expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark");
474 expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark");
475 expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({
476 canvas: canonical("#101827"),
477 text: canonical("#eef5ff"),
478 });
479 });
480
481 it("keeps the T3 Chat palette faithful and readable", () => {
482 expectThemeColors(T3_CHAT_THEME.colors, {
483 canvas: "#fdf7fd",
484 chrome: "#fdf7fd",
485 toolbarBorder: "#efbdeb",
486 toolbarControl: "#f3e6f5",
487 toolbarControlHover: "#eccfe3",
488 surfaceRaised: "#fdfafd",
489 input: "#e7c1dc",
490 focus: "#db2777",
491 messageSurface: "#f7def2",
492 codeBackground: "#f5ecf9",
493 codeForeground: "#673c8b",
494 accentSurface: "#f3e6f5",
495 sidebar: "#f2e1f4",
496 });
497 expectThemeColors(T3_CHAT_THEME.variants!.dark!, {
498 canvas: "#1f1a24",
499 chrome: "#1f1a24",
500 surface: "#29232d",
501 surfaceRaised: "#2c2631",
502 input: "#302029",
503 focus: "#db2777",
504 messageSurface: "#2b2431",
505 codeBackground: "#1f1a24",
506 sidebar: "#171018",
507 sidebarBorder: "#322028",
508 });
509
510 for (const mode of ["light", "dark"] as const) {
511 const colors = getThemeColorsForMode(T3_CHAT_THEME, mode)!;
512 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7);
513 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5);
514 expect(contrastRatio(colors.messageForeground, colors.messageSurface)).toBeGreaterThanOrEqual(
515 4.5,
516 );
517 expect(contrastRatio(colors.secondaryForeground, colors.secondary)).toBeGreaterThanOrEqual(
518 4.5,
519 );
520 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
521 expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5);
522 }
523 });
524
525 it("includes the dual-mode maintainer themes", () => {
526 for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) {
527 expect(getThemeDefinition(theme.id)).toBe(theme);
528 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
529 expect(theme.sidebarArtwork).toBe(true);
530 expect(themeAllowsSidebarArtwork(theme.id)).toBe(true);
531 expect(theme.colors.accent).toMatch(/^oklch\(/);
532 expect(theme.variants?.dark?.accent).toMatch(/^oklch\(/);
533
534 for (const mode of ["light", "dark"] as const) {
535 const colors = getThemeColorsForMode(theme, mode);
536 expect(colors).not.toBeNull();
537 expect(contrastRatio(colors!.text, colors!.canvas)).toBeGreaterThanOrEqual(4.5);
538 expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeGreaterThanOrEqual(4.5);
539 if (theme !== T3_CHAT_THEME) {
540 expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeLessThan(5.5);
541 expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeCloseTo(
542 mode === "dark" ? 5.082 : 4.705,
543 1,
544 );
545 }
546 expect(contrastRatio(colors!.accentForeground, colors!.accent)).toBeGreaterThanOrEqual(4.5);
547 expect(
548 contrastRatio(colors!.toolbarControlForeground, colors!.toolbarControl),
549 ).toBeGreaterThanOrEqual(4.5);
550 expect(
551 contrastRatio(colors!.messageForeground, colors!.messageSurface),
552 ).toBeGreaterThanOrEqual(4.5);
553 expect(
554 contrastRatio(colors!.messageActionForeground, colors!.messageAction),
555 ).toBeGreaterThanOrEqual(4.5);
556 expect(
557 contrastRatio(colors!.messageActionForeground, colors!.messageActionHover),
558 ).toBeGreaterThanOrEqual(4.5);
559 expect(contrastRatio(colors!.mutedForeground, colors!.muted)).toBeGreaterThanOrEqual(4.5);
560 expect(contrastRatio(colors!.placeholder, colors!.surfaceRaised)).toBeGreaterThanOrEqual(
561 4.5,
562 );
563 }
564 }
565 expect(themeAllowsSidebarArtwork("my-custom-theme")).toBe(false);
566 });
567
568 it("rejects a variant that repeats the base appearance", () => {
569 expect(() =>
570 parseThemeFile({
571 version: THEME_FILE_VERSION,
572 name: "Duplicate light",
573 appearance: "light",
574 colors: { canvas: "#f8fbff" },
575 variants: { light: { canvas: "#101827" } },
576 }),
577 ).toThrow('Theme variants must not repeat the base appearance "light".');
578 });
579
580 it("keeps a single-mode theme on its only palette", () => {
581 const theme = parseThemeFile({
582 version: THEME_FILE_VERSION,
583 id: "midnight-slate",
584 name: "Midnight Slate",
585 appearance: "dark",
586 colors: { canvas: "#111827", messageAction: "#2563eb" },
587 });
588
589 expect(getThemeModes(theme)).toEqual(["dark"]);
590 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({
591 canvas: canonical("#111827"),
592 });
593 expect(getThemeColorsForMode(theme, "light")).toBeNull();
594 });
595
596 it("invalidates cached themes when another tab clears localStorage", () => {
597 let storedThemes: string | null = JSON.stringify([
598 {
599 id: "ocean-dusk",
600 label: "Ocean dusk",
601 appearance: "dark",
602 colors: { canvas: "#07152f" },
603 },
604 ]);
605 let storageHandler: ((event: StorageEvent) => void) | undefined;
606 vi.stubGlobal("window", {
607 addEventListener: (type: string, listener: (event: StorageEvent) => void) => {
608 if (type === "storage") storageHandler = listener;
609 },
610 removeEventListener: vi.fn(),
611 localStorage: {
612 getItem: (key: string) => (key === CUSTOM_THEMES_STORAGE_KEY ? storedThemes : null),
613 },
614 });
615
616 invalidateCustomThemes();
617 expect(getCustomThemes()).toHaveLength(1);
618 const listener = vi.fn();
619 const unsubscribe = subscribeToCustomThemes(listener);
620
621 storedThemes = null;
622 storageHandler?.({ key: null } as StorageEvent);
623
624 expect(listener).toHaveBeenCalledTimes(1);
625 expect(getCustomThemes()).toEqual([]);
626 unsubscribe();
627 invalidateCustomThemes();
628 vi.unstubAllGlobals();
629 });
630
631 it("preserves valid imported-theme collections and drops malformed metadata", () => {
632 vi.stubGlobal("window", {
633 localStorage: {
634 getItem: (key: string) =>
635 key === CUSTOM_THEMES_STORAGE_KEY
636 ? JSON.stringify([
637 {
638 id: "github-dark",
639 label: "GitHub Dark",
640 appearance: "dark",
641 colors: { canvas: "#0d1117" },
642 collection: { id: "open-vsx:github.github-vscode-theme", label: "GitHub Theme" },
643 },
644 {
645 id: "github-light",
646 label: "GitHub Light",
647 appearance: "light",
648 colors: { canvas: "#ffffff" },
649 collection: { id: "bad collection id", label: "GitHub Theme" },
650 },
651 ])
652 : null,
653 },
654 });
655
656 invalidateCustomThemes();
657 expect(getCustomThemes()).toMatchObject([
658 { collection: { id: "open-vsx:github.github-vscode-theme", label: "GitHub Theme" } },
659 { id: "github-light" },
660 ]);
661 expect(getCustomThemes()[1]).not.toHaveProperty("collection");
662
663 vi.unstubAllGlobals();
664 invalidateCustomThemes();
665 });
666
667 it("atomically replaces an imported collection and removes stale variants", () => {
668 const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" };
669 const personalTheme = {
670 id: "personal",
671 label: "Personal",
672 appearance: "dark",
673 colors: { canvas: "#111111", futureRole: "hsl(10 20% 30%)" },
674 futureMetadata: { version: 2 },
675 };
676 const stored = new Map<string, string>([
677 [
678 CUSTOM_THEMES_STORAGE_KEY,
679 JSON.stringify([
680 personalTheme,
681 {
682 id: "old-light",
683 label: "Old Light",
684 appearance: "light",
685 colors: { canvas: "#ffffff" },
686 collection,
687 },
688 {
689 id: "removed-dark",
690 label: "Removed Dark",
691 appearance: "dark",
692 colors: { canvas: "#000000" },
693 collection,
694 },
695 ]),
696 ],
697 ]);
698 const setItem = vi.fn((key: string, value: string) => stored.set(key, value));
699 vi.stubGlobal("window", {
700 localStorage: {
701 getItem: (key: string) => stored.get(key) ?? null,
702 setItem,
703 },
704 });
705
706 invalidateCustomThemes();
707 const expectedCollection = getStoredCustomThemeCollection(collection.id);
708 getCustomThemes();
709 const concurrentlyAddedTheme = {
710 id: "other-tab",
711 label: "Other Tab",
712 appearance: "dark",
713 colors: { canvas: "#222222" },
714 };
715 stored.set(
716 CUSTOM_THEMES_STORAGE_KEY,
717 JSON.stringify([
718 ...JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]"),
719 concurrentlyAddedTheme,
720 ]),
721 );
722 const replacement = [
723 {
724 ...parseThemeFile({
725 version: THEME_FILE_VERSION,
726 id: "old-light",
727 name: "New Light",
728 appearance: "light",
729 colors: { canvas: "#fafafa" },
730 }),
731 collection,
732 },
733 {
734 ...parseThemeFile({
735 version: THEME_FILE_VERSION,
736 id: "new-dark",
737 name: "New Dark",
738 appearance: "dark",
739 colors: { canvas: "#101010" },
740 }),
741 collection,
742 },
743 ];
744
745 expect(
746 replaceCustomThemeCollection(collection.id, replacement, { expectedCollection }),
747 ).toEqual(replacement);
748 expect(getCustomThemes().map((theme) => theme.id)).toEqual([
749 "personal",
750 "old-light",
751 "new-dark",
752 "other-tab",
753 ]);
754 expect(setItem).toHaveBeenCalledTimes(1);
755 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toEqual(personalTheme);
756
757 vi.unstubAllGlobals();
758 invalidateCustomThemes();
759 });
760
761 it("replaces collection entries even when their stored collection label is malformed", () => {
762 const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" };
763 const stored = new Map<string, string>([
764 [
765 CUSTOM_THEMES_STORAGE_KEY,
766 JSON.stringify([
767 {
768 id: "old-light",
769 label: "Old Light",
770 appearance: "light",
771 colors: { canvas: "#ffffff" },
772 collection: { id: collection.id, label: 42 },
773 },
774 ]),
775 ],
776 ]);
777 vi.stubGlobal("window", {
778 localStorage: {
779 getItem: (key: string) => stored.get(key) ?? null,
780 setItem: (key: string, value: string) => stored.set(key, value),
781 },
782 });
783
784 invalidateCustomThemes();
785 const replacement = {
786 ...parseThemeFile({
787 version: THEME_FILE_VERSION,
788 id: "old-light",
789 name: "New Light",
790 appearance: "light",
791 colors: { canvas: "#fafafa" },
792 }),
793 collection,
794 };
795
796 expect(replaceCustomThemeCollection(collection.id, [replacement])).toEqual([replacement]);
797 expect(getCustomThemes()).toEqual([replacement]);
798
799 vi.unstubAllGlobals();
800 invalidateCustomThemes();
801 });
802
803 it("canonicalizes explicit writes without migrating untouched themes", () => {
804 const stored = new Map<string, string>();
805 const untouchedTheme = {
806 id: "legacy",
807 label: "Legacy",
808 appearance: "dark",
809 colors: { accent: "#5b6cff", futureRole: "hsl(10 20% 30%)" },
810 futureMetadata: { version: 2 },
811 };
812 stored.set(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify([untouchedTheme]));
813 vi.stubGlobal("window", {
814 localStorage: {
815 getItem: (key: string) => stored.get(key) ?? null,
816 setItem: (key: string, value: string) => stored.set(key, value),
817 },
818 });
819
820 invalidateCustomThemes();
821 const createdTheme = installCustomTheme(
822 parseThemeFile({
823 version: THEME_FILE_VERSION,
824 id: "aurora",
825 name: "Aurora",
826 appearance: "light",
827 colors: { canvas: "#f8fbff", accent: "#5b6cff" },
828 sidebarArtwork: true,
829 }),
830 );
831 const updatedTheme = updateCustomTheme({
832 ...createdTheme,
833 label: "Aurora Night",
834 colors: { ...createdTheme.colors, accent: "hsl(263 70% 58%)" },
835 });
836
837 expect(updatedTheme).toMatchObject({
838 id: "aurora",
839 label: "Aurora Night",
840 colors: { accent: canonical("hsl(263 70% 58%)") },
841 });
842 expect(updatedTheme).not.toHaveProperty("sidebarArtwork");
843 const storedThemes = JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]");
844 expect(storedThemes[0]).toEqual(untouchedTheme);
845 expect(storedThemes[1]).toMatchObject({
846 id: "aurora",
847 label: "Aurora Night",
848 colors: { accent: canonical("hsl(263 70% 58%)") },
849 });
850 expect(storedThemes[1]).not.toHaveProperty("sidebarArtwork");
851 invalidateCustomThemes();
852 expect(getCustomThemes().find((theme) => theme.id === "aurora")).toMatchObject({
853 id: "aurora",
854 colors: { accent: canonical("hsl(263 70% 58%)") },
855 });
856 removeCustomTheme("aurora");
857 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")).toEqual([untouchedTheme]);
858
859 vi.unstubAllGlobals();
860 invalidateCustomThemes();
861 });
862
863 it("removes multiple custom themes atomically while preserving unrelated entries", () => {
864 const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" };
865 const personalTheme = {
866 id: "personal",
867 label: "Personal",
868 appearance: "dark",
869 colors: { canvas: "#111111", futureRole: "hsl(10 20% 30%)" },
870 futureMetadata: { version: 2 },
871 };
872 const stored = new Map<string, string>([
873 [
874 CUSTOM_THEMES_STORAGE_KEY,
875 JSON.stringify([
876 personalTheme,
877 {
878 id: "demo-light",
879 label: "Demo Light",
880 appearance: "light",
881 colors: { canvas: "#ffffff" },
882 collection,
883 },
884 {
885 id: "demo-dark",
886 label: "Demo Dark",
887 appearance: "dark",
888 colors: { canvas: "#000000" },
889 collection,
890 },
891 ]),
892 ],
893 ]);
894 const setItem = vi.fn((key: string, value: string) => stored.set(key, value));
895 vi.stubGlobal("window", {
896 localStorage: {
897 getItem: (key: string) => stored.get(key) ?? null,
898 setItem,
899 },
900 });
901
902 invalidateCustomThemes();
903 removeCustomThemes(["demo-light", "demo-dark"]);
904
905 expect(setItem).toHaveBeenCalledOnce();
906 expect(getCustomThemes()).toEqual([expect.objectContaining({ id: "personal" })]);
907 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")).toEqual([personalTheme]);
908
909 vi.unstubAllGlobals();
910 invalidateCustomThemes();
911 });
912
913 it("writes from the cached raw snapshot without risking a destructive reread", () => {
914 const legacyTheme = {
915 id: "legacy",
916 label: "Legacy",
917 appearance: "dark",
918 colors: { accent: "#5b6cff" },
919 futureMetadata: true,
920 };
921 let storedThemes = JSON.stringify([legacyTheme]);
922 let readCount = 0;
923 const setItem = vi.fn((_key: string, value: string) => {
924 storedThemes = value;
925 });
926 vi.stubGlobal("window", {
927 localStorage: {
928 getItem: () => {
929 readCount += 1;
930 if (readCount > 1) throw new Error("transient read failure");
931 return storedThemes;
932 },
933 setItem,
934 },
935 });
936
937 invalidateCustomThemes();
938 expect(getCustomThemes()).toHaveLength(1);
939 installCustomTheme(
940 parseThemeFile({
941 version: THEME_FILE_VERSION,
942 id: "aurora",
943 name: "Aurora",
944 appearance: "light",
945 colors: { accent: "hsl(263 70% 58%)" },
946 }),
947 );
948
949 expect(readCount).toBe(1);
950 expect(setItem).toHaveBeenCalledOnce();
951 expect(JSON.parse(storedThemes)[0]).toEqual(legacyTheme);
952
953 vi.unstubAllGlobals();
954 invalidateCustomThemes();
955 });
956
957 it("refuses to overwrite a theme library that could not be read", () => {
958 const setItem = vi.fn();
959 vi.stubGlobal("window", {
960 localStorage: {
961 getItem: () => {
962 throw new Error("storage unavailable");
963 },
964 setItem,
965 },
966 });
967
968 invalidateCustomThemes();
969 expect(getCustomThemes()).toEqual([]);
970 expect(() =>
971 installCustomTheme(
972 parseThemeFile({
973 version: THEME_FILE_VERSION,
974 id: "aurora",
975 name: "Aurora",
976 appearance: "light",
977 colors: { accent: "#5b6cff" },
978 }),
979 ),
980 ).toThrow(`Failed to read the theme library from ${CUSTOM_THEMES_STORAGE_KEY}.`);
981 expect(setItem).not.toHaveBeenCalled();
982
983 vi.unstubAllGlobals();
984 invalidateCustomThemes();
985 });
986
987 it("rejects malformed stored entries that reuse an installed theme id", () => {
988 const storedThemes = JSON.stringify([{ id: "aurora", malformed: true }]);
989 const setItem = vi.fn();
990 vi.stubGlobal("window", {
991 localStorage: {
992 getItem: () => storedThemes,
993 setItem,
994 },
995 });
996
997 invalidateCustomThemes();
998 expect(() =>
999 installCustomTheme(
1000 parseThemeFile({
1001 version: THEME_FILE_VERSION,
1002 id: "aurora",
1003 name: "Aurora",
1004 appearance: "light",
1005 colors: { accent: "#5b6cff" },
1006 }),
1007 ),
1008 ).toThrow('A theme named "Aurora" is already installed.');
1009 expect(setItem).not.toHaveBeenCalled();
1010
1011 vi.unstubAllGlobals();
1012 invalidateCustomThemes();
1013 });
1014
1015 it("collapses duplicate raw entries when their theme is explicitly updated", () => {
1016 const stored = new Map<string, string>();
1017 const theme = {
1018 id: "aurora",
1019 label: "Aurora",
1020 appearance: "light",
1021 colors: { accent: "#5b6cff" },
1022 };
1023 const untouchedTheme = { id: "future", malformed: true, metadata: { version: 2 } };
1024 stored.set(
1025 CUSTOM_THEMES_STORAGE_KEY,
1026 JSON.stringify([theme, { id: "aurora", malformed: true }, untouchedTheme]),
1027 );
1028 vi.stubGlobal("window", {
1029 localStorage: {
1030 getItem: (key: string) => stored.get(key) ?? null,
1031 setItem: (key: string, value: string) => stored.set(key, value),
1032 },
1033 });
1034
1035 invalidateCustomThemes();
1036 const installedTheme = getCustomThemes()[0]!;
1037 updateCustomTheme({
1038 ...installedTheme,
1039 label: "Aurora Night",
1040 colors: { ...installedTheme.colors, accent: "hsl(263 70% 58%)" },
1041 });
1042
1043 const updatedLibrary = JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]");
1044 expect(updatedLibrary.filter((entry: { id?: string }) => entry.id === "aurora")).toHaveLength(
1045 1,
1046 );
1047 expect(updatedLibrary[0]).toMatchObject({
1048 id: "aurora",
1049 label: "Aurora Night",
1050 colors: { accent: canonical("hsl(263 70% 58%)") },
1051 });
1052 expect(updatedLibrary[1]).toEqual(untouchedTheme);
1053
1054 vi.unstubAllGlobals();
1055 invalidateCustomThemes();
1056 });
1057});
1058
1059describe("stored theme preferences", () => {
1060 it("lets a configured half unlock an appearance the base theme lacks", () => {
1061 // Light-only base: without halves, dark requests fall back to light.
1062 const lightOnly = parseThemeFile({
1063 version: THEME_FILE_VERSION,
1064 id: "paper",
1065 name: "Paper",
1066 appearance: "light",
1067 colors: { canvas: "#f8fbff" },
1068 });
1069 vi.stubGlobal("window", {
1070 localStorage: {
1071 getItem: (key: string) =>
1072 key === CUSTOM_THEMES_STORAGE_KEY ? JSON.stringify([lightOnly]) : null,
1073 },
1074 });
1075 invalidateCustomThemes();
1076 try {
1077 expect(resolveThemeAppearance("paper", true, true)).toBe("light");
1078 const halves = { dark: GROVE_THEME.id };
1079 expect(resolveThemeAppearance("paper", true, true, undefined, halves)).toBe("dark");
1080 expect(resolveThemeAppearance("paper", false, false, "dark", halves)).toBe("dark");
1081 expect(resolveDesktopTheme("paper", true, undefined, halves)).toBe("system");
1082 } finally {
1083 vi.unstubAllGlobals();
1084 invalidateCustomThemes();
1085 }
1086 });
1087
1088 it("resolves the legacy t3-chat-dark preference to dark T3 Chat", () => {
1089 expect(getThemeDefinition("t3-chat-dark")).toBe(T3_CHAT_THEME);
1090 expect(getThemePreferenceMode("t3-chat-dark")).toBe("dark");
1091 expect(resolveThemeAppearance("t3-chat-dark", true, false)).toBe("dark");
1092 expect(resolveDesktopTheme("t3-chat-dark", false)).toBe("dark");
1093 expect(isKnownThemePreference("t3-chat-dark")).toBe(true);
1094 });
1095
1096 it("resolves legacy t3-prefixed ids onto the renamed themes", () => {
1097 for (const [legacy, theme] of [
1098 ["t3-grove", GROVE_THEME],
1099 ["t3-ocean", OCEAN_THEME],
1100 ["t3-ember", EMBER_THEME],
1101 ["t3-iris", IRIS_THEME],
1102 ] as const) {
1103 expect(getThemeDefinition(legacy)).toBe(theme);
1104 expect(isKnownThemePreference(legacy)).toBe(true);
1105 expect(canonicalThemePreference(legacy)).toBe(theme.id);
1106 }
1107 // The dark-variant alias keeps its raw form: it still carries a mode hint.
1108 expect(canonicalThemePreference("t3-chat-dark")).toBe("t3-chat-dark");
1109 // A stored mix that predates the rename resolves to the new ids.
1110 expect(parseThemeHalves(JSON.stringify({ light: "t3-ocean", dark: "t3-grove" }))).toEqual({
1111 light: OCEAN_THEME.id,
1112 dark: GROVE_THEME.id,
1113 });
1114 });
1115
1116 it("recognizes only preferences the runtime can render", () => {
1117 for (const preference of ["light", "dark", "system", T3_CHAT_THEME.id, GROVE_THEME.id]) {
1118 expect(isKnownThemePreference(preference)).toBe(true);
1119 }
1120 expect(isKnownThemePreference(`${GROVE_THEME.id}:dark`)).toBe(false);
1121 expect(isKnownThemePreference("missing-theme")).toBe(false);
1122 });
1123
1124 it("decodes stored colors in memory without writing during reads", () => {
1125 const storedThemes = JSON.stringify([
1126 {
1127 id: "aurora",
1128 label: "Aurora",
1129 appearance: "light",
1130 colors: { canvas: "#f8fbff", futureRole: "#123456", accent: "not-a-color" },
1131 variants: { dark: { canvas: "rgb(16 24 39)" } },
1132 },
1133 { id: "light", label: "Reserved", appearance: "light", colors: {} },
1134 { id: "aurora", label: "Duplicate", appearance: "dark", colors: {} },
1135 ]);
1136 const setItem = vi.fn();
1137 vi.stubGlobal("window", {
1138 localStorage: {
1139 getItem: (key: string) => (key === CUSTOM_THEMES_STORAGE_KEY ? storedThemes : null),
1140 setItem,
1141 },
1142 });
1143 invalidateCustomThemes();
1144
1145 const themes = getCustomThemes();
1146 expect(themes).toHaveLength(1);
1147 expect(themes[0]).toMatchObject({
1148 id: "aurora",
1149 colors: { canvas: canonical("#f8fbff"), accent: getDefaultThemeColors("light").accent },
1150 });
1151 expect(getThemeModes(themes[0]!)).toEqual(["light", "dark"]);
1152 expect(getThemeColorsForMode(themes[0]!, "dark")?.canvas).toBe(canonical("rgb(16 24 39)"));
1153 expect(setItem).not.toHaveBeenCalled();
1154 expect(JSON.parse(storedThemes)[0].colors).toEqual({
1155 canvas: "#f8fbff",
1156 futureRole: "#123456",
1157 accent: "not-a-color",
1158 });
1159
1160 vi.unstubAllGlobals();
1161 invalidateCustomThemes();
1162 });
1163});
1164
1165describe("singleAppearanceOf", () => {
1166 it("reports the only half a theme can claim, and null for a pair", () => {
1167 const { variants: _pair, ...base } = T3_CHAT_THEME;
1168 expect(singleAppearanceOf({ ...base, id: "x", appearance: "dark" })).toBe("dark");
1169 expect(singleAppearanceOf(T3_CHAT_THEME)).toBe(null);
1170 });
1171});