| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { getThemeColorsForMode, themeColorToHex, THEME_FILE_VERSION } from "./themePalette"; |
| 4 | import { |
| 5 | isVsCodeThemeFile, |
| 6 | pairVsCodeThemes, |
| 7 | parseVsCodeThemeFile, |
| 8 | resolveThemeLabelCollisions, |
| 9 | } from "./vscodeThemeImport"; |
| 10 | |
| 11 | function 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 | |
| 17 | function 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. |
| 35 | const 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 | |
| 58 | describe("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 | }); |