Open History to see the last commit
1import { describe, expect, it, vi } from "vite-plus/test";
2import { converter } from "culori/fn";
3import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
4
5import { CONTEXT_CHIP_ACCENTS, contextChipColorVariable } from "./lib/contextChipAccents";
6
7import {
8 applyThemePalette,
9 contextChipVariables,
10 getThemeColorsForMode,
11 getThemeModes,
12 getCustomThemes,
13 getStandardThemeColors,
14 getStoredCustomThemeCollection,
15 invalidateCustomThemes,
16 installCustomTheme,
17 parseThemeFile,
18 removeCustomTheme,
19 replaceCustomThemeCollection,
20 resolveDesktopTheme,
21 resolveThemeAppearance,
22 serializeThemeFile,
23 T3_CHAT_THEME,
24 GROVE_THEME,
25 updateCustomTheme,
26 updateThemeColorFamily,
27 CUSTOM_THEMES_STORAGE_KEY,
28 createVividThemeColors,
29 getDefaultThemeColors,
30 themeColorToHex,
31 toCanonicalThemeColor,
32 THEME_FILE_VERSION,
33} from "./themePalette";
34
35function asHex(value: string): string {
36 const hex = themeColorToHex(value);
37 if (!hex) throw new Error(`Expected a theme color, received ${value}`);
38 return hex.slice(0, 7);
39}
40
41function canonical(value: string): string {
42 const color = toCanonicalThemeColor(value);
43 if (!color) throw new Error(`Expected a theme color, received ${value}`);
44 return color;
45}
46
47function contrastRatio(first: string, second: string): number {
48 const toRgb = (value: string) => {
49 const hex = asHex(value).slice(1);
50 return [0, 1, 2].map(
51 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
52 );
53 };
54 const luminance = (value: string) =>
55 toRgb(value)
56 .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
57 .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0);
58 const lighter = Math.max(luminance(first), luminance(second));
59 const darker = Math.min(luminance(first), luminance(second));
60 return (lighter + 0.05) / (darker + 0.05);
61}
62
63describe("theme files", () => {
64 it("gives non-Latin theme names distinct stable ids", () => {
65 const file = {
66 version: THEME_FILE_VERSION,
67 appearance: "dark",
68 colors: { canvas: "#101820" },
69 };
70 const night = parseThemeFile({ ...file, name: "夜" });
71 expect(parseThemeFile({ ...file, name: "朝" }).id).not.toBe(night.id);
72 expect(parseThemeFile({ ...file, name: " 夜 " }).id).toBe(night.id);
73 });
74
75 it("keeps built-in and standard search pairs readable and distinct from inline code", () => {
76 const toOklab = converter("oklab");
77 const distance = (first: string, second: string) => {
78 const a = toOklab(first)!;
79 const b = toOklab(second)!;
80 return Math.hypot(a.l - b.l, a.a - b.a, a.b - b.b);
81 };
82 const palettes = [
83 getStandardThemeColors("light"),
84 getStandardThemeColors("dark"),
85 ...BUILT_IN_THEMES.flatMap((theme) => [theme.colors, ...Object.values(theme.variants ?? {})]),
86 ];
87 for (const colors of palettes) {
88 expect(
89 contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground),
90 ).toBeGreaterThanOrEqual(4.5);
91 expect(
92 contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground),
93 ).toBeGreaterThanOrEqual(4.5);
94 expect(
95 distance(colors.searchMatchBackground, colors.searchMatchActiveBackground),
96 ).toBeGreaterThan(0.14);
97 for (const background of [colors.muted, colors.messageSurface, colors.codeBackground]) {
98 expect(distance(colors.searchMatchBackground, background)).toBeGreaterThan(0.08);
99 expect(distance(colors.searchMatchActiveBackground, background)).toBeGreaterThan(0.08);
100 }
101 }
102 });
103
104 it("round-trips custom search colours and fills them in for older theme files", () => {
105 const legacy = parseThemeFile({
106 version: THEME_FILE_VERSION,
107 name: "Legacy",
108 appearance: "dark",
109 colors: { canvas: "#101010" },
110 });
111 expect(legacy.colors.searchMatchBackground).toBe(
112 getDefaultThemeColors("dark").searchMatchBackground,
113 );
114 const theme = parseThemeFile({
115 version: THEME_FILE_VERSION,
116 name: "Search",
117 appearance: "dark",
118 colors: {
119 searchMatchBackground: "#224466",
120 searchMatchForeground: "#ffffff",
121 searchMatchActiveBackground: "#aaddff",
122 searchMatchActiveForeground: "#112233",
123 },
124 variants: { light: { searchMatchBackground: "#ddeeff", searchMatchForeground: "#112233" } },
125 });
126 const restored = parseThemeFile(JSON.parse(serializeThemeFile(theme)));
127 expect(restored.colors).toEqual(theme.colors);
128 expect(restored.variants).toEqual(theme.variants);
129 });
130
131 it.each(["searchMatchBackground", "searchMatchActiveBackground"] as const)(
132 "editing %s derives readable text without changing the other match or warning colours",
133 (role) => {
134 const original = getDefaultThemeColors("dark");
135 const foreground =
136 role === "searchMatchBackground" ? "searchMatchForeground" : "searchMatchActiveForeground";
137 for (const value of ["#ffffff", "#000000", "#33669980"]) {
138 const updated = updateThemeColorFamily("dark", original, role, value);
139 expect(contrastRatio(updated[foreground], updated[role])).toBeGreaterThanOrEqual(4.5);
140 for (const key of Object.keys(original) as Array<keyof typeof original>) {
141 if (key !== role && key !== foreground) expect(updated[key]).toBe(original[key]);
142 }
143 }
144 },
145 );
146
147 it("derives readable, distinctive vivid palettes from exact seeds", () => {
148 const seeds: ReadonlyArray<["light" | "dark", string, string]> = [
149 ["light", "#f4f9f2", "#1d8a4e"],
150 ["dark", "#101a2c", "#4f8fe8"],
151 ["dark", "#211a23", "#df5398"],
152 ["light", "#fdf6ec", "#c2571b"],
153 ["light", "#111827", "#8ab4f8"],
154 ["dark", "#f5ecf5", "#a84370"],
155 ];
156 const channels = (value: string) =>
157 [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [
158 number,
159 number,
160 number,
161 ];
162 for (const [appearance, canvas, accent] of seeds) {
163 const colors = createVividThemeColors(appearance, canvas, accent);
164 expect(colors.canvas).toMatch(/^oklch\(/);
165 expect(colors.accent).toMatch(/^oklch\(/);
166 expect(asHex(colors.canvas)).toBe(canvas);
167 expect(asHex(colors.accent)).toBe(accent);
168 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7);
169 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5);
170 expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5);
171 expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5);
172 expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeLessThan(
173 contrastRatio(colors.text, colors.muted),
174 );
175 expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5);
176 expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan(
177 contrastRatio(colors.text, colors.surfaceRaised),
178 );
179 expect(colors.secondaryLabel).toBe(colors.textMuted);
180 expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5);
181 expect(
182 contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground),
183 ).toBeGreaterThanOrEqual(4.5);
184 expect(
185 contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground),
186 ).toBeGreaterThanOrEqual(4.5);
187 expect(
188 contrastRatio(colors.messageActionForeground, colors.messageAction),
189 ).toBeGreaterThanOrEqual(4.5);
190 expect(contrastRatio(colors.messageForeground, colors.messageSurface)).toBeGreaterThanOrEqual(
191 4.5,
192 );
193 expect(contrastRatio(colors.secondaryForeground, colors.secondary)).toBeGreaterThanOrEqual(
194 4.5,
195 );
196 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
197 expect(colors.messageAction).not.toBe(colors.accent);
198 expect(asHex(colors.update)).toBe(accent);
199 const [errorRed, errorGreen, errorBlue] = channels(colors.error);
200 expect(errorRed).toBeGreaterThan(errorGreen * 2);
201 expect(errorRed).toBeGreaterThan(errorBlue * 2);
202 expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5);
203 expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual(
204 4.5,
205 );
206 const [warnRed, warnGreen, warnBlue] = channels(colors.warning);
207 expect(warnRed).toBeGreaterThan(warnBlue);
208 expect(warnGreen).toBeGreaterThan(warnBlue);
209 }
210 });
211
212 it("decodes literal CSS color formats into OKLCH without dropping alpha", () => {
213 const theme = parseThemeFile({
214 version: THEME_FILE_VERSION,
215 name: "Translucent",
216 appearance: "light",
217 colors: {
218 canvas: "oklch(62% 0.2 280deg / 50%)",
219 accent: "#abcd",
220 focus: "rgb(10 20 30 / 50%)",
221 error: "hsl(350 80% 50%)",
222 warning: "hwb(45 10% 20%)",
223 update: "lab(60% 40 30)",
224 messageAction: "lch(60% 50 120)",
225 sidebar: "oklab(0.6 0.1 -0.1)",
226 terminalCursor: "color(display-p3 0.8 0.2 0.3)",
227 terminalSelection: "rebeccapurple",
228 terminalScrollbar: "transparent",
229 terminalScrollbarHover: "rgb(10 20 30 / none)",
230 },
231 });
232
233 expect(theme.colors.canvas).toBe("oklch(0.62 0.2 280 / 0.5)");
234 expect(theme.colors.accent).toBe(canonical("#abcd"));
235 expect(themeColorToHex(theme.colors.accent)).toBe("#aabbccdd");
236 expect(themeColorToHex(theme.colors.focus)).toBe("#0a141e80");
237 expect(themeColorToHex(theme.colors.terminalSelection)).toBe("#663399");
238 expect(theme.colors.terminalScrollbar).toBe("oklch(0 0 0 / 0)");
239 expect(themeColorToHex(theme.colors.terminalScrollbarHover)).toBe("#0a141e00");
240 for (const role of [
241 "error",
242 "warning",
243 "update",
244 "messageAction",
245 "sidebar",
246 "terminalCursor",
247 ] as const) {
248 expect(theme.colors[role]).toMatch(/^oklch\(/);
249 }
250 });
251
252 it("gamut maps extreme finite OKLCH chroma from theme files", () => {
253 const theme = parseThemeFile({
254 version: THEME_FILE_VERSION,
255 name: "Extreme chroma",
256 appearance: "light",
257 colors: { accent: "oklch(0.5 1e303 0)" },
258 });
259
260 expect(theme.colors.accent).toBe("oklch(0.5 1e+303 0)");
261 expect(themeColorToHex(theme.colors.accent)).toBe("#b5005e");
262 });
263
264 it("keeps optional light and dark palettes under one theme id", () => {
265 const theme = parseThemeFile({
266 version: THEME_FILE_VERSION,
267 id: "aurora",
268 name: "Aurora",
269 appearance: "light",
270 colors: { canvas: "#f8fbff", text: "#10243d" },
271 variants: {
272 dark: { canvas: "#101827", text: "#eef5ff" },
273 },
274 });
275
276 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
277 expect(getThemeColorsForMode(theme, "dark")).toMatchObject({
278 canvas: canonical("#101827"),
279 text: canonical("#eef5ff"),
280 });
281 expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]);
282 expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark");
283 expect(resolveDesktopTheme(T3_CHAT_THEME.id, true)).toBe("system");
284 expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark");
285 expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark");
286 expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({
287 canvas: canonical("#101827"),
288 text: canonical("#eef5ff"),
289 });
290 });
291
292 it("preserves valid imported-theme collections and drops malformed metadata", () => {
293 vi.stubGlobal("window", {
294 localStorage: {
295 getItem: (key: string) =>
296 key === CUSTOM_THEMES_STORAGE_KEY
297 ? JSON.stringify([
298 {
299 id: "github-dark",
300 label: "GitHub Dark",
301 appearance: "dark",
302 colors: { canvas: "#0d1117" },
303 collection: { id: "open-vsx:github.github-vscode-theme", label: "GitHub Theme" },
304 },
305 {
306 id: "github-light",
307 label: "GitHub Light",
308 appearance: "light",
309 colors: { canvas: "#ffffff" },
310 collection: { id: "bad collection id", label: "GitHub Theme" },
311 },
312 ])
313 : null,
314 },
315 });
316
317 invalidateCustomThemes();
318 expect(getCustomThemes()).toMatchObject([
319 { collection: { id: "open-vsx:github.github-vscode-theme", label: "GitHub Theme" } },
320 { id: "github-light" },
321 ]);
322 expect(getCustomThemes()[1]).not.toHaveProperty("collection");
323
324 vi.unstubAllGlobals();
325 invalidateCustomThemes();
326 });
327
328 it("atomically replaces an imported collection and removes stale variants", () => {
329 const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" };
330 const personalTheme = {
331 id: "personal",
332 label: "Personal",
333 appearance: "dark",
334 colors: { canvas: "#111111", futureRole: "hsl(10 20% 30%)" },
335 futureMetadata: { version: 2 },
336 };
337 const stored = new Map<string, string>([
338 [
339 CUSTOM_THEMES_STORAGE_KEY,
340 JSON.stringify([
341 personalTheme,
342 {
343 id: "old-light",
344 label: "Old Light",
345 appearance: "light",
346 colors: { canvas: "#ffffff" },
347 collection,
348 },
349 {
350 id: "removed-dark",
351 label: "Removed Dark",
352 appearance: "dark",
353 colors: { canvas: "#000000" },
354 collection,
355 },
356 ]),
357 ],
358 ]);
359 const setItem = vi.fn((key: string, value: string) => stored.set(key, value));
360 vi.stubGlobal("window", {
361 localStorage: {
362 getItem: (key: string) => stored.get(key) ?? null,
363 setItem,
364 },
365 });
366
367 invalidateCustomThemes();
368 const expectedCollection = getStoredCustomThemeCollection(collection.id);
369 getCustomThemes();
370 const concurrentlyAddedTheme = {
371 id: "other-tab",
372 label: "Other Tab",
373 appearance: "dark",
374 colors: { canvas: "#222222" },
375 };
376 stored.set(
377 CUSTOM_THEMES_STORAGE_KEY,
378 JSON.stringify([
379 ...JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]"),
380 concurrentlyAddedTheme,
381 ]),
382 );
383 const replacement = [
384 {
385 ...parseThemeFile({
386 version: THEME_FILE_VERSION,
387 id: "old-light",
388 name: "New Light",
389 appearance: "light",
390 colors: { canvas: "#fafafa" },
391 }),
392 collection,
393 },
394 {
395 ...parseThemeFile({
396 version: THEME_FILE_VERSION,
397 id: "new-dark",
398 name: "New Dark",
399 appearance: "dark",
400 colors: { canvas: "#101010" },
401 }),
402 collection,
403 },
404 ];
405
406 expect(
407 replaceCustomThemeCollection(collection.id, replacement, { expectedCollection }),
408 ).toEqual(replacement);
409 expect(getCustomThemes().map((theme) => theme.id)).toEqual([
410 "personal",
411 "old-light",
412 "new-dark",
413 "other-tab",
414 ]);
415 expect(setItem).toHaveBeenCalledTimes(1);
416 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toEqual(personalTheme);
417
418 vi.unstubAllGlobals();
419 invalidateCustomThemes();
420 });
421
422 it("canonicalizes explicit writes without migrating untouched themes", () => {
423 const stored = new Map<string, string>();
424 const untouchedTheme = {
425 id: "legacy",
426 label: "Legacy",
427 appearance: "dark",
428 colors: { accent: "#5b6cff", futureRole: "hsl(10 20% 30%)" },
429 futureMetadata: { version: 2 },
430 };
431 stored.set(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify([untouchedTheme]));
432 vi.stubGlobal("window", {
433 localStorage: {
434 getItem: (key: string) => stored.get(key) ?? null,
435 setItem: (key: string, value: string) => stored.set(key, value),
436 },
437 });
438
439 invalidateCustomThemes();
440 const createdTheme = installCustomTheme(
441 parseThemeFile({
442 version: THEME_FILE_VERSION,
443 id: "aurora",
444 name: "Aurora",
445 appearance: "light",
446 colors: { canvas: "#f8fbff", accent: "#5b6cff" },
447 sidebarArtwork: true,
448 }),
449 );
450 const updatedTheme = updateCustomTheme({
451 ...createdTheme,
452 label: "Aurora Night",
453 colors: { ...createdTheme.colors, accent: "hsl(263 70% 58%)" },
454 });
455
456 expect(updatedTheme).toMatchObject({
457 id: "aurora",
458 label: "Aurora Night",
459 colors: { accent: canonical("hsl(263 70% 58%)") },
460 });
461 expect(updatedTheme).not.toHaveProperty("sidebarArtwork");
462 const storedThemes = JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]");
463 expect(storedThemes[0]).toEqual(untouchedTheme);
464 expect(storedThemes[1]).toMatchObject({
465 id: "aurora",
466 label: "Aurora Night",
467 colors: { accent: canonical("hsl(263 70% 58%)") },
468 });
469 expect(storedThemes[1]).not.toHaveProperty("sidebarArtwork");
470 invalidateCustomThemes();
471 expect(getCustomThemes().find((theme) => theme.id === "aurora")).toMatchObject({
472 id: "aurora",
473 colors: { accent: canonical("hsl(263 70% 58%)") },
474 });
475 removeCustomTheme("aurora");
476 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")).toEqual([untouchedTheme]);
477
478 vi.unstubAllGlobals();
479 invalidateCustomThemes();
480 });
481
482 it("writes from the cached raw snapshot without risking a destructive reread", () => {
483 const legacyTheme = {
484 id: "legacy",
485 label: "Legacy",
486 appearance: "dark",
487 colors: { accent: "#5b6cff" },
488 futureMetadata: true,
489 };
490 let storedThemes = JSON.stringify([legacyTheme]);
491 let readCount = 0;
492 const setItem = vi.fn((_key: string, value: string) => {
493 storedThemes = value;
494 });
495 vi.stubGlobal("window", {
496 localStorage: {
497 getItem: () => {
498 readCount += 1;
499 if (readCount > 1) throw new Error("transient read failure");
500 return storedThemes;
501 },
502 setItem,
503 },
504 });
505
506 invalidateCustomThemes();
507 expect(getCustomThemes()).toHaveLength(1);
508 installCustomTheme(
509 parseThemeFile({
510 version: THEME_FILE_VERSION,
511 id: "aurora",
512 name: "Aurora",
513 appearance: "light",
514 colors: { accent: "hsl(263 70% 58%)" },
515 }),
516 );
517
518 expect(readCount).toBe(1);
519 expect(setItem).toHaveBeenCalledOnce();
520 expect(JSON.parse(storedThemes)[0]).toEqual(legacyTheme);
521
522 vi.unstubAllGlobals();
523 invalidateCustomThemes();
524 });
525
526 it("refuses to overwrite a theme library that could not be read", () => {
527 const setItem = vi.fn();
528 vi.stubGlobal("window", {
529 localStorage: {
530 getItem: () => {
531 throw new Error("storage unavailable");
532 },
533 setItem,
534 },
535 });
536
537 invalidateCustomThemes();
538 expect(getCustomThemes()).toEqual([]);
539 expect(() =>
540 installCustomTheme(
541 parseThemeFile({
542 version: THEME_FILE_VERSION,
543 id: "aurora",
544 name: "Aurora",
545 appearance: "light",
546 colors: { accent: "#5b6cff" },
547 }),
548 ),
549 ).toThrow(`Failed to read the theme library from ${CUSTOM_THEMES_STORAGE_KEY}.`);
550 expect(setItem).not.toHaveBeenCalled();
551
552 vi.unstubAllGlobals();
553 invalidateCustomThemes();
554 });
555});
556
557describe("context chip colors", () => {
558 it.each([
559 ["#ffffff", 5],
560 ["#777777", 4.5],
561 ] as const)(
562 "chooses readable chip lightness on %s when the text is medium gray",
563 (surface, minimum) => {
564 const colors = {
565 ...getStandardThemeColors("light"),
566 canvas: surface,
567 surface,
568 surfaceRaised: surface,
569 messageSurface: surface,
570 text: "#767676",
571 messageForeground: "#767676",
572 };
573 for (const color of Object.values(contextChipVariables(colors))) {
574 expect(contrastRatio(color, surface)).toBeGreaterThanOrEqual(minimum);
575 }
576 },
577 );
578
579 const toOklch = converter("oklch");
580 const toRgb = converter("rgb");
581 // Surfaces stay unrounded, as CSS paints them; the chip is the 8-bit hex the solver returns.
582 const paintedContrastRatio = (chip: string, surface: string, canvas: string) => {
583 const channels = (value: string) => {
584 const rgb = toRgb(value)!;
585 return [rgb.r, rgb.g, rgb.b].map((channel) => Math.min(1, Math.max(0, channel)));
586 };
587 const back = channels(canvas);
588 const alpha = toRgb(surface)!.alpha ?? 1;
589 const painted = channels(surface).map(
590 (channel, index) => back[index]! * (1 - alpha) + channel * alpha,
591 );
592 const luminance = (rgb: number[]) =>
593 rgb
594 .map((channel) =>
595 channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
596 )
597 .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0);
598 const first = luminance(channels(asHex(chip)));
599 const second = luminance(painted);
600 return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
601 };
602 const palettes = [
603 getStandardThemeColors("light"),
604 getStandardThemeColors("dark"),
605 ...BUILT_IN_THEMES.flatMap((theme) =>
606 getThemeModes(theme).map((mode) => getThemeColorsForMode(theme, mode)!),
607 ),
608 ];
609
610 it("reads at 5:1 on every chip surface of every built-in theme and keeps each hue", () => {
611 for (const colors of palettes) {
612 const variables = contextChipVariables(colors);
613 expect(variables["--context-chip-message"]).toBeUndefined();
614 for (const [kind, accent] of Object.entries(CONTEXT_CHIP_ACCENTS)) {
615 const chip =
616 variables[contextChipColorVariable(kind as keyof typeof CONTEXT_CHIP_ACCENTS)]!;
617 for (const surface of [
618 colors.canvas,
619 colors.surface,
620 colors.surfaceRaised,
621 colors.messageSurface,
622 ]) {
623 expect(paintedContrastRatio(chip, surface, colors.canvas)).toBeGreaterThanOrEqual(5);
624 }
625 const solved = toOklch(chip)!;
626 if ((solved.c ?? 0) > 0.04) {
627 const drift = Math.abs((((solved.h ?? 0) - toOklch(accent)!.h! + 540) % 360) - 180);
628 expect(drift).toBeLessThan(4);
629 }
630 }
631 }
632 });
633
634 it("reads at 5:1 on the standard surfaces as index.css paints them", () => {
635 const painted = {
636 light: ["#fcfcfc", "#ffffff", "#ebebed"],
637 dark: ["#000000", "#0a0a0a", "#111111", "#141414", "#1e1e1e"],
638 } as const;
639 const properties = new Map<string, string>();
640 vi.stubGlobal("window", { localStorage: { getItem: () => null } });
641 vi.stubGlobal("document", {
642 documentElement: {
643 dataset: {},
644 style: {
645 setProperty: (name: string, value: string) => properties.set(name, value),
646 removeProperty: (name: string) => properties.delete(name),
647 },
648 },
649 });
650 invalidateCustomThemes();
651 try {
652 for (const appearance of ["light", "dark"] as const) {
653 applyThemePalette("system", appearance);
654 for (const kind of Object.keys(CONTEXT_CHIP_ACCENTS) as Array<
655 keyof typeof CONTEXT_CHIP_ACCENTS
656 >) {
657 for (const surface of painted[appearance]) {
658 expect(
659 contrastRatio(properties.get(contextChipColorVariable(kind))!, surface),
660 ).toBeGreaterThanOrEqual(5);
661 }
662 }
663 }
664 } finally {
665 vi.unstubAllGlobals();
666 invalidateCustomThemes();
667 }
668 });
669
670 it("solves the invalid state's error color to 5:1 on every chip surface", () => {
671 for (const colors of palettes) {
672 const invalid = contextChipVariables(colors)["--context-chip-invalid"];
673 expect(invalid).toBeDefined();
674 for (const surface of [
675 colors.canvas,
676 colors.surface,
677 colors.surfaceRaised,
678 colors.messageSurface,
679 ]) {
680 expect(paintedContrastRatio(invalid!, surface, colors.canvas)).toBeGreaterThanOrEqual(5);
681 }
682 }
683 });
684
685 it("leaves a bubble with opposite-shade text out of the shared solve", () => {
686 const colors = {
687 ...getStandardThemeColors("light"),
688 messageSurface: "#1b4ed8",
689 messageForeground: "#ffffff",
690 };
691 const variables = contextChipVariables(colors);
692 expect(variables["--context-chip-message"]).toBe("#ffffff");
693 for (const kind of Object.keys(CONTEXT_CHIP_ACCENTS) as Array<
694 keyof typeof CONTEXT_CHIP_ACCENTS
695 >) {
696 expect(
697 contrastRatio(variables[contextChipColorVariable(kind)]!, colors.canvas),
698 ).toBeGreaterThanOrEqual(5);
699 }
700 expect(toOklch(variables[contextChipColorVariable("file")])!.c).toBeGreaterThan(0.08);
701 });
702});
703
704describe("stored theme preferences", () => {
705 it("lets a configured half unlock an appearance the base theme lacks", () => {
706 const lightOnly = parseThemeFile({
707 version: THEME_FILE_VERSION,
708 id: "paper",
709 name: "Paper",
710 appearance: "light",
711 colors: { canvas: "#f8fbff" },
712 });
713 vi.stubGlobal("window", {
714 localStorage: {
715 getItem: (key: string) =>
716 key === CUSTOM_THEMES_STORAGE_KEY ? JSON.stringify([lightOnly]) : null,
717 },
718 });
719 invalidateCustomThemes();
720 try {
721 expect(resolveThemeAppearance("paper", true, true)).toBe("light");
722 const halves = { dark: GROVE_THEME.id };
723 expect(resolveThemeAppearance("paper", true, true, undefined, halves)).toBe("dark");
724 expect(resolveThemeAppearance("paper", false, false, "dark", halves)).toBe("dark");
725 expect(resolveDesktopTheme("paper", true, undefined, halves)).toBe("system");
726 } finally {
727 vi.unstubAllGlobals();
728 invalidateCustomThemes();
729 }
730 });
731
732 it("decodes stored colors in memory without writing during reads", () => {
733 const storedThemes = JSON.stringify([
734 {
735 id: "aurora",
736 label: "Aurora",
737 appearance: "light",
738 colors: { canvas: "#f8fbff", futureRole: "#123456", accent: "not-a-color" },
739 variants: { dark: { canvas: "rgb(16 24 39)" } },
740 },
741 { id: "light", label: "Reserved", appearance: "light", colors: {} },
742 { id: "aurora", label: "Duplicate", appearance: "dark", colors: {} },
743 ]);
744 const setItem = vi.fn();
745 vi.stubGlobal("window", {
746 localStorage: {
747 getItem: (key: string) => (key === CUSTOM_THEMES_STORAGE_KEY ? storedThemes : null),
748 setItem,
749 },
750 });
751 invalidateCustomThemes();
752
753 const themes = getCustomThemes();
754 expect(themes).toHaveLength(1);
755 expect(themes[0]).toMatchObject({
756 id: "aurora",
757 colors: { canvas: canonical("#f8fbff"), accent: getDefaultThemeColors("light").accent },
758 });
759 expect(getThemeModes(themes[0]!)).toEqual(["light", "dark"]);
760 expect(getThemeColorsForMode(themes[0]!, "dark")?.canvas).toBe(canonical("rgb(16 24 39)"));
761 expect(setItem).not.toHaveBeenCalled();
762 expect(JSON.parse(storedThemes)[0].colors).toEqual({
763 canvas: "#f8fbff",
764 futureRole: "#123456",
765 accent: "not-a-color",
766 });
767
768 vi.unstubAllGlobals();
769 invalidateCustomThemes();
770 });
771});