1import { describe, expect, it } from "vite-plus/test";
2
3import { getThemeColorsForMode, themeColorToHex, THEME_FILE_VERSION } from "./themePalette";
4import {
5 isVsCodeThemeFile,
6 pairVsCodeThemes,
7 parseVsCodeThemeFile,
8 resolveThemeLabelCollisions,
9} from "./vscodeThemeImport";
10
11function asHex(value: string): string {
12 const hex = themeColorToHex(value);
13 if (!hex) throw new Error(`Expected a theme color, received ${value}`);
14 return hex;
15}
16
17function contrastRatio(first: string, second: string): number {
18 const toChannels = (value: string) => {
19 const hex = asHex(value).slice(1);
20 return [0, 1, 2].map(
21 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
22 );
23 };
24 const luminance = (value: string) =>
25 toChannels(value)
26 .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
27 .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0);
28 const lighter = Math.max(luminance(first), luminance(second));
29 const darker = Math.min(luminance(first), luminance(second));
30 return (lighter + 0.05) / (darker + 0.05);
31}
32
33// Shaped like a real workbench theme: dotted keys, alpha overlays, and a lot
34// of roles simply left out.
35const VSCODE_DARK = {
36 name: "pierre-dark-soft",
37 type: "dark",
38 colors: {
39 "editor.background": "#171717",
40 "editor.foreground": "#d4d4d4",
41 foreground: "#d4d4d4",
42 "sideBar.background": "#101010",
43 "sideBar.foreground": "#8a8a8a",
44 "sideBar.border": "#1d1d1d",
45 focusBorder: "#69b1ff",
46 "button.background": "#69b1ff",
47 "button.foreground": "#171717",
48 "input.border": "#2c2c2c",
49 "input.placeholderForeground": "#525252",
50 "terminal.background": "#101010",
51 "terminal.foreground": "#8a8a8a",
52 "list.hoverBackground": "#1f3e5e59",
53 "list.activeSelectionBackground": "#1f3e5e99",
54 },
55 tokenColors: [],
56};
57
58describe("VS Code theme import", () => {
59 it("recognises workbench themes and rejects our own files", () => {
60 expect(isVsCodeThemeFile(VSCODE_DARK)).toBe(true);
61 expect(isVsCodeThemeFile({ type: "dark", tokenColors: [] })).toBe(true);
62 expect(
63 isVsCodeThemeFile({
64 version: THEME_FILE_VERSION,
65 name: "Aurora",
66 appearance: "light",
67 colors: { canvas: "#ffffff" },
68 }),
69 ).toBe(false);
70 expect(isVsCodeThemeFile("nope")).toBe(false);
71 });
72
73 it("carries the editor surfaces and accent across", () => {
74 const theme = parseVsCodeThemeFile(VSCODE_DARK);
75 // The slug name is read as words; a displayName would win verbatim.
76 expect(theme.label).toBe("Pierre Dark Soft");
77 expect(theme.appearance).toBe("dark");
78 expect(asHex(theme.colors.canvas)).toBe("#171717");
79 expect(asHex(theme.colors.text)).toBe("#d4d4d4");
80 expect(asHex(theme.colors.accent)).toBe("#69b1ff");
81 expect(asHex(theme.colors.sidebar)).toBe("#101010");
82 expect(asHex(theme.colors.terminalBackground)).toBe("#101010");
83 });
84
85 it("flattens alpha overlays onto the surface they sit on", () => {
86 const theme = parseVsCodeThemeFile(VSCODE_DARK);
87 // #1f3e5e59 over the #101010 sidebar, not left semi-transparent.
88 expect(theme.colors.sidebarRowHover).toMatch(/^oklch\(/);
89 expect(asHex(theme.colors.sidebarRowHover)).not.toBe("#1f3e5e59");
90 expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar);
91 });
92
93 it("keeps a fallback placeholder dimmer than entered text", () => {
94 const theme = parseVsCodeThemeFile({
95 name: "Dark placeholder fallback",
96 type: "dark",
97 colors: {
98 "editor.background": "#1e1e2e",
99 "editor.foreground": "#cdd6f4",
100 "input.placeholderForeground": "#cdd6f473",
101 },
102 });
103
104 expect(
105 contrastRatio(theme.colors.placeholder, theme.colors.surfaceRaised),
106 ).toBeGreaterThanOrEqual(4.5);
107 expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeGreaterThanOrEqual(
108 4.5,
109 );
110 expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeLessThan(
111 contrastRatio(theme.colors.text, theme.colors.canvas),
112 );
113 });
114
115 it("moves reserved VS Code names to a non-reserved id", () => {
116 const darkPlus = parseVsCodeThemeFile({
117 name: "Dark+",
118 type: "dark",
119 colors: { "editor.background": "#1e1e1e" },
120 });
121 expect(darkPlus.label).toBe("Dark+");
122 expect(darkPlus.id).not.toBe("dark");
123 expect(darkPlus.id).toBe("dark-vscode");
124 });
125
126 it("keeps Dark+ and Light+ as separate themes instead of pairing them as +", () => {
127 const themes = pairVsCodeThemes([
128 parseVsCodeThemeFile({
129 name: "Light+",
130 type: "light",
131 colors: { "editor.background": "#ffffff" },
132 }),
133 parseVsCodeThemeFile({
134 name: "Dark+",
135 type: "dark",
136 colors: { "editor.background": "#1e1e1e" },
137 }),
138 ]);
139 expect(themes.map((theme) => [theme.id, theme.label])).toEqual([
140 ["light-vscode", "Light+"],
141 ["dark-vscode", "Dark+"],
142 ]);
143 });
144
145 it("prefers a visible input background over a transparent border", () => {
146 const catppuccin = parseVsCodeThemeFile({
147 name: "Catppuccin Mocha",
148 type: "dark",
149 colors: {
150 "editor.background": "#1e1e2e",
151 "input.border": "#00000000",
152 "input.background": "#313244",
153 },
154 });
155 expect(asHex(catppuccin.colors.input)).toBe("#313244");
156 });
157
158 it("keeps unchecked inputs distinct from the checked action color", () => {
159 const gruvbox = parseVsCodeThemeFile({
160 name: "Gruvbox Light Soft",
161 type: "light",
162 colors: {
163 "editor.background": "#f2e5bc",
164 "button.background": "#45858880",
165 "input.background": "#45858880",
166 "input.border": "#928374",
167 },
168 });
169 expect(
170 contrastRatio(gruvbox.colors.input, gruvbox.colors.messageAction),
171 ).toBeGreaterThanOrEqual(1.1);
172 });
173
174 it("keeps the derived input distinct when a button reuses it", () => {
175 const theme = parseVsCodeThemeFile({
176 name: "Derived input collision",
177 type: "dark",
178 colors: {
179 "editor.background": "#1e1e2e",
180 focusBorder: "#89b4fa",
181 "button.background": "#525661",
182 },
183 });
184
185 expect(asHex(theme.colors.messageAction)).toBe("#525661");
186 expect(asHex(theme.colors.input)).not.toBe("#525661");
187 expect(contrastRatio(theme.colors.input, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1);
188 expect(contrastRatio(theme.colors.input, theme.colors.messageAction)).toBeGreaterThanOrEqual(
189 1.1,
190 );
191 });
192
193 it("skips a transparent focus border for a visible accent key", () => {
194 const vitesse = parseVsCodeThemeFile({
195 name: "Vitesse Dark",
196 type: "dark",
197 colors: {
198 "editor.background": "#121212",
199 focusBorder: "#00000000",
200 "button.background": "#4d9375",
201 },
202 });
203 expect(asHex(vitesse.colors.accent)).toBe("#4d9375");
204 expect(asHex(vitesse.colors.focus)).toBe("#4d9375");
205 expect(contrastRatio(vitesse.colors.focus, vitesse.colors.canvas)).toBeGreaterThanOrEqual(1.1);
206 });
207
208 it("keeps focus visible against an explicit raised surface", () => {
209 const theme = parseVsCodeThemeFile({
210 name: "Raised focus",
211 type: "dark",
212 colors: {
213 "editor.background": "#000000",
214 focusBorder: "#111111",
215 "editorWidget.background": "#111111",
216 "button.background": "#4d9375",
217 },
218 });
219 expect(contrastRatio(theme.colors.focus, theme.colors.surfaceRaised)).toBeGreaterThanOrEqual(
220 1.1,
221 );
222 expect(asHex(theme.colors.focus)).toBe("#4d9375");
223 });
224
225 it("keeps the fallback focus visible against an explicit raised surface", () => {
226 const theme = parseVsCodeThemeFile({
227 name: "Raised fallback focus",
228 type: "dark",
229 colors: {
230 "editor.background": "#121212",
231 "editorWidget.background": "#346bf1",
232 },
233 });
234 expect(asHex(theme.colors.focus)).toBe("#ffffff");
235 expect(contrastRatio(theme.colors.focus, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1);
236 expect(contrastRatio(theme.colors.focus, theme.colors.surfaceRaised)).toBeGreaterThanOrEqual(
237 1.1,
238 );
239 });
240
241 it("skips a transparent button background for the action color", () => {
242 const theme = parseVsCodeThemeFile({
243 name: "Transparent button",
244 type: "dark",
245 colors: {
246 "editor.background": "#121212",
247 focusBorder: "#4d9375",
248 "button.background": "#00000000",
249 },
250 });
251 expect(asHex(theme.colors.messageAction)).toBe("#4d9375");
252 });
253
254 it("skips a button background that matches the raised surface", () => {
255 const theme = parseVsCodeThemeFile({
256 name: "Raised button",
257 type: "dark",
258 colors: {
259 "editor.background": "#121212",
260 focusBorder: "#4d9375",
261 "editorWidget.background": "#2a2d3a",
262 "button.background": "#2a2d3a",
263 },
264 });
265 expect(asHex(theme.colors.messageAction)).toBe("#4d9375");
266 });
267
268 it("uses a visible default accent when the file has no usable accent key", () => {
269 const theme = parseVsCodeThemeFile({
270 name: "No accent",
271 type: "dark",
272 colors: { "editor.background": "#121212", focusBorder: "#121212" },
273 });
274 expect(contrastRatio(theme.colors.focus, theme.colors.canvas)).toBeGreaterThanOrEqual(1.1);
275 });
276
277 it("validates placeholders against the resolved raised surface", () => {
278 const lightPlus = parseVsCodeThemeFile({
279 name: "Light Plus Shape",
280 type: "light",
281 colors: {
282 "editor.background": "#eaeff3",
283 "editor.foreground": "#1f1f1f",
284 "editorWidget.background": "#ffffff",
285 "input.placeholderForeground": "#767676",
286 },
287 });
288 expect(
289 contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised),
290 ).toBeGreaterThanOrEqual(4.5);
291 expect(
292 contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised),
293 ).toBeLessThan(contrastRatio(lightPlus.colors.text, lightPlus.colors.surfaceRaised));
294 });
295 it("imports current and other find matches as independent background/text pairs", () => {
296 const { colors } = parseVsCodeThemeFile({
297 type: "dark",
298 colors: {
299 "editor.background": "#202020",
300 "editor.findMatchBackground": "#663399",
301 "editor.findMatchForeground": "#ffffff",
302 "editor.findMatchHighlightBackground": "#bce4ff",
303 "editor.findMatchHighlightForeground": "#17314a",
304 },
305 });
306
307 expect(asHex(colors.searchMatchActiveBackground)).toBe("#663399");
308 expect(asHex(colors.searchMatchActiveForeground)).toBe("#ffffff");
309 expect(asHex(colors.searchMatchBackground)).toBe("#bce4ff");
310 expect(asHex(colors.searchMatchForeground)).toBe("#17314a");
311 });
312
313 it("composites find backgrounds onto the canvas and foregrounds onto their match", () => {
314 const { colors } = parseVsCodeThemeFile({
315 type: "dark",
316 colors: {
317 "editor.background": "#202020",
318 "editor.findMatchBackground": "#33669980",
319 "editor.findMatchForeground": "#ffffffcc",
320 "editor.findMatchHighlightBackground": "#ffcc0040",
321 "editor.findMatchHighlightForeground": "#ffffff",
322 },
323 });
324
325 expect(asHex(colors.searchMatchActiveBackground)).toBe("#2a435d");
326 expect(asHex(colors.searchMatchActiveForeground)).toBe("#d4d9df");
327 expect(asHex(colors.searchMatchBackground)).toBe("#584b18");
328 expect(asHex(colors.searchMatchForeground)).toBe("#ffffff");
329 });
330
331 it("uses editor text when no find foreground is specified", () => {
332 const { colors } = parseVsCodeThemeFile({
333 type: "dark",
334 colors: {
335 "editor.background": "#101010",
336 "editor.foreground": "#eeeeee",
337 "editor.findMatchBackground": "#304050",
338 "editor.findMatchHighlightBackground": "#403020",
339 },
340 });
341
342 expect(asHex(colors.searchMatchActiveForeground)).toBe("#eeeeee");
343 expect(asHex(colors.searchMatchForeground)).toBe("#eeeeee");
344 });
345
346 it.each(["light", "dark"])(
347 "keeps missing or invalid find colours readable in %s themes",
348 (type) => {
349 const base = {
350 "editor.background": type === "dark" ? "#101010" : "#fafafa",
351 };
352 const missing = parseVsCodeThemeFile({ type, colors: base }).colors;
353 const invalid = parseVsCodeThemeFile({
354 type,
355 colors: {
356 ...base,
357 "editor.findMatchBackground": "not-a-colour",
358 "editor.findMatchForeground": 42,
359 "editor.findMatchHighlightBackground": null,
360 "editor.findMatchHighlightForeground": "invalid",
361 },
362 }).colors;
363 const unreadable = parseVsCodeThemeFile({
364 type,
365 colors: {
366 ...base,
367 "editor.findMatchBackground": "#ffffcc",
368 "editor.findMatchForeground": "#ffffff",
369 "editor.findMatchHighlightBackground": "#101020",
370 "editor.findMatchHighlightForeground": "#111111",
371 },
372 }).colors;
373
374 for (const colors of [missing, invalid, unreadable]) {
375 expect(
376 contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground),
377 ).toBeGreaterThanOrEqual(4.5);
378 expect(
379 contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground),
380 ).toBeGreaterThanOrEqual(4.5);
381 }
382 expect(invalid.searchMatchBackground).toBe(missing.searchMatchBackground);
383 expect(invalid.searchMatchActiveBackground).toBe(missing.searchMatchActiveBackground);
384 expect(missing.searchMatchBackground).not.toBe(missing.searchMatchActiveBackground);
385 expect(missing.searchMatchBackground).not.toBe(missing.canvas);
386 expect(missing.searchMatchActiveBackground).not.toBe(missing.canvas);
387 },
388 );
389
390 it("fills every role the file omits with a readable derived value", () => {
391 const theme = parseVsCodeThemeFile(VSCODE_DARK);
392 const colors = getThemeColorsForMode(theme, "dark")!;
393 for (const value of Object.values(colors)) {
394 expect(value).toMatch(/^oklch\(/);
395 }
396 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(4.5);
397 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
398 expect(
399 contrastRatio(colors.messageActionForeground, colors.messageAction),
400 ).toBeGreaterThanOrEqual(4.5);
401 });
402
403 it("falls back to the editor background when the type is missing or odd", () => {
404 const untyped = parseVsCodeThemeFile({
405 name: "Untyped",
406 colors: { "editor.background": "#fdfdfd", "editor.foreground": "#202020" },
407 });
408 expect(untyped.appearance).toBe("light");
409 const hc = parseVsCodeThemeFile({
410 name: "High contrast",
411 type: "hc-light",
412 colors: { "editor.background": "#ffffff" },
413 });
414 expect(hc.appearance).toBe("light");
415 });
416
417 it("keeps an unreadable foreground out of the palette", () => {
418 const theme = parseVsCodeThemeFile({
419 name: "Unreadable",
420 type: "dark",
421 colors: { "editor.background": "#101010", "editor.foreground": "#111111" },
422 });
423 expect(asHex(theme.colors.text)).not.toBe("#111111");
424 expect(contrastRatio(theme.colors.text, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5);
425 });
426
427 it("stays readable when the file replaces a surface but not its foreground", () => {
428 // A dark theme with a light sidebar: the derived sidebar foreground was
429 // solved for a dark surface and would vanish on this one.
430 const theme = parseVsCodeThemeFile({
431 name: "Split",
432 type: "dark",
433 colors: {
434 "editor.background": "#101010",
435 "editor.foreground": "#f5f5f5",
436 "sideBar.background": "#fafafa",
437 "terminal.background": "#fbfbfb",
438 },
439 });
440 expect(asHex(theme.colors.sidebar)).toBe("#fafafa");
441 expect(
442 contrastRatio(theme.colors.sidebarForeground, theme.colors.sidebar),
443 ).toBeGreaterThanOrEqual(4.5);
444 expect(
445 contrastRatio(theme.colors.terminalForeground, theme.colors.terminalBackground),
446 ).toBeGreaterThanOrEqual(4.5);
447 });
448
449 it("reads wide-gamut color() notation", () => {
450 // Themes authored for P3 displays (the shipped Pierre "vibrant" pair) use
451 // this instead of hex.
452 const theme = parseVsCodeThemeFile({
453 name: "Vibrant",
454 type: "dark",
455 colors: {
456 "editor.background": "color(display-p3 0.039216 0.039216 0.039216)",
457 "editor.foreground": "color(display-p3 0.980392 0.980392 0.980392)",
458 focusBorder: "color(display-p3 0.308664 0.645271 1.000000)",
459 "editor.selectionBackground": "color(display-p3 0.308664 0.645271 1.000000 / 0.300000)",
460 },
461 });
462 expect(asHex(theme.colors.canvas)).toMatch(/^#0[89ab]/);
463 expect(asHex(theme.colors.text)).toMatch(/^#f[a-f0-9]/);
464 // The P3 blue lands in sRGB blue, not black or a clipped grey.
465 const accent = asHex(theme.colors.accent);
466 const [red, green, blue] = [1, 3, 5].map((index) =>
467 Number.parseInt(accent.slice(index, index + 2), 16),
468 ) as [number, number, number];
469 expect(blue).toBeGreaterThan(200);
470 expect(blue).toBeGreaterThan(red);
471 expect(green).toBeGreaterThan(red);
472 });
473
474 it("pairs light and dark files from one family into dual-mode themes", () => {
475 const make = (name: string, type: "light" | "dark") =>
476 parseVsCodeThemeFile({
477 name,
478 type,
479 colors: {
480 "editor.background": type === "dark" ? "#101014" : "#fdfdfd",
481 "editor.foreground": type === "dark" ? "#e6e6e6" : "#1f1f1f",
482 focusBorder: "#69b1ff",
483 },
484 });
485 const themes = pairVsCodeThemes([
486 make("github-dark", "dark"),
487 make("github-light", "light"),
488 make("github-dark-colorblind", "dark"),
489 make("github-light-colorblind", "light"),
490 make("github-dark-dimmed", "dark"),
491 ]);
492
493 const labels = themes.map((theme) => theme.label);
494 expect(labels).toEqual(["Github", "Github Colorblind", "Github Dark Dimmed"]);
495 const github = themes[0]!;
496 expect(github.appearance).toBe("light");
497 expect(getThemeColorsForMode(github, "dark")).not.toBeNull();
498 expect(asHex(getThemeColorsForMode(github, "dark")!.canvas)).toBe("#101014");
499 expect(asHex(github.colors.canvas)).toBe("#fdfdfd");
500 // The unpaired dimmed variant stays a single dark theme.
501 expect(getThemeColorsForMode(themes[2]!, "light")).toBeNull();
502 });
503
504 it("does not guess when a family is ambiguous", () => {
505 const make = (name: string, type: "light" | "dark") =>
506 parseVsCodeThemeFile({
507 name,
508 type,
509 colors: { "editor.background": type === "dark" ? "#101014" : "#fdfdfd" },
510 });
511 const themes = pairVsCodeThemes([
512 make("solar-dark", "dark"),
513 make("solar-dark-soft", "dark"),
514 make("solar-light", "light"),
515 ]);
516 // "solar-dark-soft" groups under its own key with no light partner, so
517 // it keeps its full name; the remaining pair merges.
518 expect(themes.map((theme) => theme.label).sort()).toEqual(["Solar", "Solar Dark Soft"]);
519 });
520
521 it("keeps derived surfaces neutral instead of washing them with the accent", () => {
522 // A gray theme with a blue focusBorder: roles the file omits (code
523 // surface, plain surfaces) must stay near the canvas, not turn blue.
524 const theme = parseVsCodeThemeFile(VSCODE_DARK);
525 const spread = (value: string) => {
526 const hex = asHex(value);
527 const channels = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
528 return Math.max(...channels) - Math.min(...channels);
529 };
530 expect(spread(theme.colors.codeBackground)).toBeLessThanOrEqual(8);
531 expect(spread(theme.colors.surface)).toBeLessThanOrEqual(8);
532 expect(spread(theme.colors.text)).toBeLessThanOrEqual(12);
533 // The accent itself keeps the file's color.
534 expect(asHex(theme.colors.accent)).toBe("#69b1ff");
535 });
536
537 it("tells same-named variants apart by their file names", () => {
538 // Dracula ships dracula.json and dracula-soft.json that both say
539 // "Dracula" inside.
540 const dracula = (bg: string) =>
541 parseVsCodeThemeFile({
542 name: "Dracula",
543 type: "dark",
544 colors: { "editor.background": bg, "editor.foreground": "#f8f8f2" },
545 });
546 const themes = resolveThemeLabelCollisions([
547 { theme: dracula("#282a36"), sourceName: "dracula.json" },
548 { theme: dracula("#22232e"), sourceName: "dracula-soft.json" },
549 ]);
550 expect(themes.map((theme) => theme.label)).toEqual(["Dracula", "Dracula Soft"]);
551 expect(themes.map((theme) => theme.id)).toEqual(["dracula", "dracula-soft"]);
552 // Without file names the second falls back to numbering.
553 const numbered = resolveThemeLabelCollisions([
554 { theme: dracula("#282a36") },
555 { theme: dracula("#22232e") },
556 ]);
557 expect(numbered.map((theme) => theme.label)).toEqual(["Dracula", "Dracula 2"]);
558 });
559
560 it("falls back to the name when the displayName humanizes to nothing", () => {
561 const theme = parseVsCodeThemeFile({
562 displayName: "---",
563 name: "night-owl",
564 type: "dark",
565 colors: { "editor.background": "#011627" },
566 });
567 expect(theme.label).toBe("Night Owl");
568 });
569
570 it("keeps a pair whose stripped name is reserved as two single themes", () => {
571 const make = (name: string, type: "light" | "dark") =>
572 parseVsCodeThemeFile({
573 name,
574 type,
575 colors: { "editor.background": type === "dark" ? "#101014" : "#fdfdfd" },
576 });
577 const themes = pairVsCodeThemes([make("grove-light", "light"), make("grove-dark", "dark")]);
578 expect(themes.map((theme) => theme.label).sort()).toEqual(["Grove Dark", "Grove Light"]);
579 });
580
581 it("explains a file with no editor background", () => {
582 expect(() => parseVsCodeThemeFile({ name: "Empty", type: "dark", colors: {} })).toThrow(
583 /editor\.background/,
584 );
585 });
586});