| 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { converter } from "culori/fn"; |
| 3 | import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; |
| 4 | |
| 5 | import { |
| 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 | |
| 47 | function 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 | |
| 53 | function 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 | |
| 59 | function 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 | |
| 68 | function 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 | |
| 84 | describe("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 | |
| 1059 | describe("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 | |
| 1165 | describe("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 | }); |