import { describe, expect, it, vi } from "vite-plus/test";import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; import { applyThemeColorPreview, applyThemePalette, getThemeColorsForMode, getThemeDefinition, getThemeModes, getThemePreviewSidebarArtwork, getThemePreferenceMode, isKnownThemePreference, getCustomThemes, getStandardThemeColors, getStoredCustomThemeCollection, invalidateCustomThemes, installCustomTheme, canonicalThemePreference, parseThemeFile, parseThemeHalves, removeCustomTheme, removeCustomThemes, replaceCustomThemeCollection, resolveDesktopTheme, resolveThemeAppearance, serializeThemeFile, subscribeToThemePreview, subscribeToCustomThemes, themeAllowsSidebarArtwork, T3_CHAT_THEME, EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, updateCustomTheme, CUSTOM_THEMES_STORAGE_KEY, createVividThemeColors, getDefaultThemeColors, themeColorToHex, toCanonicalThemeColor, THEME_FILE_VERSION, singleAppearanceOf,} from "./themePalette"; function asHex(value: string): string { const hex = themeColorToHex(value); if (!hex) throw new Error(`Expected a theme color, received ${value}`); return hex.slice(0, 7);} function canonical(value: string): string { const color = toCanonicalThemeColor(value); if (!color) throw new Error(`Expected a theme color, received ${value}`); return color;} function expectThemeColors( colors: Readonly<Record<string, string>>, expected: Readonly<Record<string, string>>,): void { for (const [role, value] of Object.entries(expected)) { expect(asHex(colors[role]!)).toBe(value); }} function contrastRatio(first: string, second: string): number { const toRgb = (value: string) => { const hex = asHex(value).slice(1); return [0, 1, 2].map( (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255, ); }; const luminance = (value: string) => toRgb(value) .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)) .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0); const lighter = Math.max(luminance(first), luminance(second)); const darker = Math.min(luminance(first), luminance(second)); return (lighter + 0.05) / (darker + 0.05);} describe("theme files", () => { it("keeps every built-in palette value in canonical OKLCH form", () => { for (const theme of BUILT_IN_THEMES) { for (const colors of [theme.colors, ...Object.values(theme.variants ?? {})]) { for (const value of Object.values(colors)) { expect(toCanonicalThemeColor(value)).toBe(value); } } } }); it("keeps stock dark controls in the neutral-black surface hierarchy", () => { expectThemeColors(getStandardThemeColors("dark"), { canvas: "#0a0a0a", surface: "#111111", surfaceRaised: "#111111", surfaceOverlay: "#111111", toolbarControl: "#111111", secondary: "#111111", muted: "#111111", accentSurface: "#141414", }); }); it("derives readable, distinctive vivid palettes from exact seeds", () => { const seeds: ReadonlyArray<["light" | "dark", string, string]> = [ ["light", "#f4f9f2", "#1d8a4e"], ["dark", "#101a2c", "#4f8fe8"], ["dark", "#211a23", "#df5398"], ["light", "#fdf6ec", "#c2571b"], // Inverted canvases: the palette follows the picked color, not the slot. ["light", "#111827", "#8ab4f8"], ["dark", "#f5ecf5", "#a84370"], ]; const channels = (value: string) => [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ number, number, number, ]; for (const [appearance, canvas, accent] of seeds) { const colors = createVividThemeColors(appearance, canvas, accent); // Exact seeds are honored. expect(colors.canvas).toMatch(/^oklch\(/); expect(colors.accent).toMatch(/^oklch\(/); expect(asHex(colors.canvas)).toBe(canvas); expect(asHex(colors.accent)).toBe(accent); // Readability is solved per surface. expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7); expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5); expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeLessThan( contrastRatio(colors.text, colors.muted), ); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan( contrastRatio(colors.text, colors.surfaceRaised), ); expect(colors.secondaryLabel).toBe(colors.textMuted); expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors.messageActionForeground, colors.messageAction), ).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.messageForeground, colors.messageSurface)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.secondaryForeground, colors.secondary)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5); // The companion action is a distinct voice, not the accent again. expect(colors.messageAction).not.toBe(colors.accent); // Update family follows the theme, not the default palette. expect(asHex(colors.update)).toBe(accent); // Semantic statuses stay red and amber instead of inheriting a brand tint. const [errorRed, errorGreen, errorBlue] = channels(colors.error); expect(errorRed).toBeGreaterThan(errorGreen * 2); expect(errorRed).toBeGreaterThan(errorBlue * 2); expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( 4.5, ); const [warnRed, warnGreen, warnBlue] = channels(colors.warning); expect(warnRed).toBeGreaterThan(warnBlue); expect(warnGreen).toBeGreaterThan(warnBlue); } }); it("keys status colors off the canvas, not the appearance slot", () => { // Inverted seeds: a dark canvas in the light slot must still get the dark // status pair, or the alert foreground lands on a dark surface unreadable. const inverted = [ createVividThemeColors("light", "#111827", "#8ab4f8"), createVividThemeColors("dark", "#f5ecf5", "#a84370"), createVividThemeColors("light", "#0d1117", "#69b1ff"), createVividThemeColors("dark", "#fdfdfd", "#c2571b"), ]; for (const colors of inverted) { expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.warningForeground, colors.warningSurface)).toBeGreaterThanOrEqual( 4.5, ); } }); it("merges a small user file onto the matching contrast-safe base palette", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Ocean dusk", appearance: "dark", colors: { canvas: "#07152f", accent: "#67c2ff", }, }); expect(theme).toMatchObject({ id: "ocean-dusk", label: "Ocean dusk", appearance: "dark", colors: { canvas: canonical("#07152f"), accent: canonical("#67c2ff"), placeholder: canonical("#968d9f"), }, }); }); it("decodes literal CSS color formats into OKLCH without dropping alpha", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Translucent", appearance: "light", colors: { canvas: "oklch(62% 0.2 280deg / 50%)", accent: "#abcd", focus: "rgb(10 20 30 / 50%)", error: "hsl(350 80% 50%)", warning: "hwb(45 10% 20%)", update: "lab(60% 40 30)", messageAction: "lch(60% 50 120)", sidebar: "oklab(0.6 0.1 -0.1)", terminalCursor: "color(display-p3 0.8 0.2 0.3)", terminalSelection: "rebeccapurple", terminalScrollbar: "transparent", terminalScrollbarHover: "rgb(10 20 30 / none)", }, }); expect(theme.colors.canvas).toBe("oklch(0.62 0.2 280 / 0.5)"); expect(theme.colors.accent).toBe(canonical("#abcd")); expect(themeColorToHex(theme.colors.accent)).toBe("#aabbccdd"); expect(themeColorToHex(theme.colors.focus)).toBe("#0a141e80"); expect(themeColorToHex(theme.colors.terminalSelection)).toBe("#663399"); expect(theme.colors.terminalScrollbar).toBe("oklch(0 0 0 / 0)"); expect(themeColorToHex(theme.colors.terminalScrollbarHover)).toBe("#0a141e00"); for (const role of [ "error", "warning", "update", "messageAction", "sidebar", "terminalCursor", ] as const) { expect(theme.colors[role]).toMatch(/^oklch\(/); } }); it("gamut maps extreme finite OKLCH chroma from theme files", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Extreme chroma", appearance: "light", colors: { accent: "oklch(0.5 1e303 0)" }, }); expect(theme.colors.accent).toBe("oklch(0.5 1e+303 0)"); expect(themeColorToHex(theme.colors.accent)).toBe("#b5005e"); }); it("rejects unknown roles and invalid color values", () => { expect(() => parseThemeFile({ version: THEME_FILE_VERSION, name: "Broken", appearance: "light", colors: { background: "#ffffff" }, }), ).toThrow('"background" is not a supported theme color role.'); expect(() => parseThemeFile({ version: THEME_FILE_VERSION, name: "Broken", appearance: "light", colors: { accent: "var(--danger)" }, }), ).toThrow('The color for "accent" must be a literal CSS color'); }); it("canonicalizes the explicitly exported theme", () => { const serialized = serializeThemeFile({ ...T3_CHAT_THEME, colors: { ...T3_CHAT_THEME.colors, accent: "hsl(263 70% 58%)" }, }); expect(JSON.parse(serialized)).toMatchObject({ version: THEME_FILE_VERSION, id: T3_CHAT_THEME.id, name: T3_CHAT_THEME.label, appearance: "light", colors: { accent: canonical("hsl(263 70% 58%)") }, }); }); it("serializes a theme back into the importable file shape", () => { const theme = { ...parseThemeFile({ version: THEME_FILE_VERSION, id: "community-demo", name: "Community Demo", appearance: "dark", colors: { canvas: "#111111" }, }), collection: { id: "open-vsx:demo.theme", label: "Demo Theme" }, }; const serialized = serializeThemeFile(theme); expect(JSON.parse(serialized)).toMatchObject({ version: THEME_FILE_VERSION, id: theme.id, name: theme.label, appearance: "dark", collection: theme.collection, }); expect(parseThemeFile(JSON.parse(serialized)).collection).toEqual(theme.collection); }); it("keeps sidebar artwork disabled for custom theme files", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Art sidebar", appearance: "light", colors: { accent: "#5b6cff" }, sidebarArtwork: true, }); expect(theme.sidebarArtwork).toBeUndefined(); expect(JSON.parse(serializeThemeFile(theme))).not.toHaveProperty("sidebarArtwork"); }); it("suppresses sidebar artwork during a live custom-theme preview", () => { const listener = vi.fn(); const unsubscribe = subscribeToThemePreview(listener); vi.stubGlobal("document", { documentElement: { classList: { toggle: vi.fn() }, dataset: {}, style: { removeProperty: vi.fn(), setProperty: vi.fn() }, }, }); applyThemeColorPreview(T3_CHAT_THEME.colors, "light"); expect(getThemePreviewSidebarArtwork()).toBe(false); expect(listener).toHaveBeenCalledTimes(1); applyThemePalette("system"); expect(getThemePreviewSidebarArtwork()).toBeNull(); expect(listener).toHaveBeenCalledTimes(2); unsubscribe(); vi.unstubAllGlobals(); }); it("keeps optional light and dark palettes under one theme id", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, id: "aurora", name: "Aurora", appearance: "light", colors: { canvas: "#f8fbff", text: "#10243d" }, variants: { dark: { canvas: "#101827", text: "#eef5ff" }, }, }); expect(getThemeModes(theme)).toEqual(["light", "dark"]); expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ canvas: canonical("#101827"), text: canonical("#eef5ff"), }); expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]); expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark"); expect(resolveDesktopTheme(T3_CHAT_THEME.id, true)).toBe("system"); expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark"); expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark"); expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({ canvas: canonical("#101827"), text: canonical("#eef5ff"), }); }); it("keeps the T3 Chat palette faithful and readable", () => { expectThemeColors(T3_CHAT_THEME.colors, { canvas: "#fdf7fd", chrome: "#fdf7fd", toolbarBorder: "#efbdeb", toolbarControl: "#f3e6f5", toolbarControlHover: "#eccfe3", surfaceRaised: "#fdfafd", input: "#e7c1dc", focus: "#db2777", messageSurface: "#f7def2", codeBackground: "#f5ecf9", codeForeground: "#673c8b", accentSurface: "#f3e6f5", sidebar: "#f2e1f4", }); expectThemeColors(T3_CHAT_THEME.variants!.dark!, { canvas: "#1f1a24", chrome: "#1f1a24", surface: "#29232d", surfaceRaised: "#2c2631", input: "#302029", focus: "#db2777", messageSurface: "#2b2431", codeBackground: "#1f1a24", sidebar: "#171018", sidebarBorder: "#322028", }); for (const mode of ["light", "dark"] as const) { const colors = getThemeColorsForMode(T3_CHAT_THEME, mode)!; expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7); expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.messageForeground, colors.messageSurface)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.secondaryForeground, colors.secondary)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5); } }); it("includes the dual-mode maintainer themes", () => { for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) { expect(getThemeDefinition(theme.id)).toBe(theme); expect(getThemeModes(theme)).toEqual(["light", "dark"]); expect(theme.sidebarArtwork).toBe(true); expect(themeAllowsSidebarArtwork(theme.id)).toBe(true); expect(theme.colors.accent).toMatch(/^oklch\(/); expect(theme.variants?.dark?.accent).toMatch(/^oklch\(/); for (const mode of ["light", "dark"] as const) { const colors = getThemeColorsForMode(theme, mode); expect(colors).not.toBeNull(); expect(contrastRatio(colors!.text, colors!.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeGreaterThanOrEqual(4.5); if (theme !== T3_CHAT_THEME) { expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeLessThan(5.5); expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeCloseTo( mode === "dark" ? 5.082 : 4.705, 1, ); } expect(contrastRatio(colors!.accentForeground, colors!.accent)).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors!.toolbarControlForeground, colors!.toolbarControl), ).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors!.messageForeground, colors!.messageSurface), ).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors!.messageActionForeground, colors!.messageAction), ).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors!.messageActionForeground, colors!.messageActionHover), ).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors!.mutedForeground, colors!.muted)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors!.placeholder, colors!.surfaceRaised)).toBeGreaterThanOrEqual( 4.5, ); } } expect(themeAllowsSidebarArtwork("my-custom-theme")).toBe(false); }); it("rejects a variant that repeats the base appearance", () => { expect(() => parseThemeFile({ version: THEME_FILE_VERSION, name: "Duplicate light", appearance: "light", colors: { canvas: "#f8fbff" }, variants: { light: { canvas: "#101827" } }, }), ).toThrow('Theme variants must not repeat the base appearance "light".'); }); it("keeps a single-mode theme on its only palette", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, id: "midnight-slate", name: "Midnight Slate", appearance: "dark", colors: { canvas: "#111827", messageAction: "#2563eb" }, }); expect(getThemeModes(theme)).toEqual(["dark"]); expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ canvas: canonical("#111827"), }); expect(getThemeColorsForMode(theme, "light")).toBeNull(); }); it("invalidates cached themes when another tab clears localStorage", () => {Lines 1–500 of 1075. Load more
import { describe, expect, it, vi } from "vite-plus/test"; import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; import { applyThemeColorPreview, applyThemePalette, getThemeColorsForMode, getThemeDefinition, getThemeModes, getThemePreviewSidebarArtwork, getThemePreferenceMode, isKnownThemePreference, getCustomThemes, getStandardThemeColors, getStoredCustomThemeCollection, invalidateCustomThemes, installCustomTheme, canonicalThemePreference, parseThemeFile, parseThemeHalves, removeCustomTheme, removeCustomThemes, replaceCustomThemeCollection, resolveDesktopTheme, resolveThemeAppearance, serializeThemeFile, subscribeToThemePreview, subscribeToCustomThemes, themeAllowsSidebarArtwork, T3_CHAT_THEME, EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, updateCustomTheme, CUSTOM_THEMES_STORAGE_KEY, createVividThemeColors, getDefaultThemeColors, themeColorToHex, toCanonicalThemeColor, THEME_FILE_VERSION, singleAppearanceOf, } from "./themePalette"; function asHex(value: string): string { const hex = themeColorToHex(value); if (!hex) throw new Error(`Expected a theme color, received ${value}`); return hex.slice(0, 7); } function canonical(value: string): string { const color = toCanonicalThemeColor(value); if (!color) throw new Error(`Expected a theme color, received ${value}`); return color; } function expectThemeColors( colors: Readonly<Record<string, string>>, expected: Readonly<Record<string, string>>, ): void { for (const [role, value] of Object.entries(expected)) { expect(asHex(colors[role]!)).toBe(value); } } function contrastRatio(first: string, second: string): number { const toRgb = (value: string) => { const hex = asHex(value).slice(1); return [0, 1, 2].map( (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255, ); }; const luminance = (value: string) => toRgb(value) .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)) .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0); const lighter = Math.max(luminance(first), luminance(second)); const darker = Math.min(luminance(first), luminance(second)); return (lighter + 0.05) / (darker + 0.05); } describe("theme files", () => { it("keeps every built-in palette value in canonical OKLCH form", () => { for (const theme of BUILT_IN_THEMES) { for (const colors of [theme.colors, ...Object.values(theme.variants ?? {})]) { for (const value of Object.values(colors)) { expect(toCanonicalThemeColor(value)).toBe(value); } } } }); it("keeps stock dark controls in the neutral-black surface hierarchy", () => { expectThemeColors(getStandardThemeColors("dark"), { canvas: "#0a0a0a", surface: "#111111", surfaceRaised: "#111111", surfaceOverlay: "#111111", toolbarControl: "#111111", secondary: "#111111", muted: "#111111", accentSurface: "#141414", }); }); it("derives readable, distinctive vivid palettes from exact seeds", () => { const seeds: ReadonlyArray<["light" | "dark", string, string]> = [ ["light", "#f4f9f2", "#1d8a4e"], ["dark", "#101a2c", "#4f8fe8"], ["dark", "#211a23", "#df5398"], ["light", "#fdf6ec", "#c2571b"], // Inverted canvases: the palette follows the picked color, not the slot. ["light", "#111827", "#8ab4f8"], ["dark", "#f5ecf5", "#a84370"], ]; const channels = (value: string) => [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ number, number, number, ]; for (const [appearance, canvas, accent] of seeds) { const colors = createVividThemeColors(appearance, canvas, accent); // Exact seeds are honored. expect(colors.canvas).toMatch(/^oklch\(/); expect(colors.accent).toMatch(/^oklch\(/); expect(asHex(colors.canvas)).toBe(canvas); expect(asHex(colors.accent)).toBe(accent); // Readability is solved per surface. expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7); expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5); expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeLessThan( contrastRatio(colors.text, colors.muted), ); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan( contrastRatio(colors.text, colors.surfaceRaised), ); expect(colors.secondaryLabel).toBe(colors.textMuted); expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors.messageActionForeground, colors.messageAction), ).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.messageForeground, colors.messageSurface)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.secondaryForeground, colors.secondary)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5); // The companion action is a distinct voice, not the accent again. expect(colors.messageAction).not.toBe(colors.accent); // Update family follows the theme, not the default palette. expect(asHex(colors.update)).toBe(accent); // Semantic statuses stay red and amber instead of inheriting a brand tint. const [errorRed, errorGreen, errorBlue] = channels(colors.error); expect(errorRed).toBeGreaterThan(errorGreen * 2); expect(errorRed).toBeGreaterThan(errorBlue * 2); expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( 4.5, ); const [warnRed, warnGreen, warnBlue] = channels(colors.warning); expect(warnRed).toBeGreaterThan(warnBlue); expect(warnGreen).toBeGreaterThan(warnBlue); } }); it("keys status colors off the canvas, not the appearance slot", () => { // Inverted seeds: a dark canvas in the light slot must still get the dark // status pair, or the alert foreground lands on a dark surface unreadable. const inverted = [ createVividThemeColors("light", "#111827", "#8ab4f8"), createVividThemeColors("dark", "#f5ecf5", "#a84370"), createVividThemeColors("light", "#0d1117", "#69b1ff"), createVividThemeColors("dark", "#fdfdfd", "#c2571b"), ]; for (const colors of inverted) { expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.warningForeground, colors.warningSurface)).toBeGreaterThanOrEqual( 4.5, ); } }); it("merges a small user file onto the matching contrast-safe base palette", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Ocean dusk", appearance: "dark", colors: { canvas: "#07152f", accent: "#67c2ff", }, }); expect(theme).toMatchObject({ id: "ocean-dusk", label: "Ocean dusk", appearance: "dark", colors: { canvas: canonical("#07152f"), accent: canonical("#67c2ff"), placeholder: canonical("#968d9f"), }, }); }); it("decodes literal CSS color formats into OKLCH without dropping alpha", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Translucent", appearance: "light", colors: { canvas: "oklch(62% 0.2 280deg / 50%)", accent: "#abcd", focus: "rgb(10 20 30 / 50%)", error: "hsl(350 80% 50%)", warning: "hwb(45 10% 20%)", update: "lab(60% 40 30)", messageAction: "lch(60% 50 120)", sidebar: "oklab(0.6 0.1 -0.1)", terminalCursor: "color(display-p3 0.8 0.2 0.3)", terminalSelection: "rebeccapurple", terminalScrollbar: "transparent", terminalScrollbarHover: "rgb(10 20 30 / none)", }, }); expect(theme.colors.canvas).toBe("oklch(0.62 0.2 280 / 0.5)"); expect(theme.colors.accent).toBe(canonical("#abcd")); expect(themeColorToHex(theme.colors.accent)).toBe("#aabbccdd"); expect(themeColorToHex(theme.colors.focus)).toBe("#0a141e80"); expect(themeColorToHex(theme.colors.terminalSelection)).toBe("#663399"); expect(theme.colors.terminalScrollbar).toBe("oklch(0 0 0 / 0)"); expect(themeColorToHex(theme.colors.terminalScrollbarHover)).toBe("#0a141e00"); for (const role of [ "error", "warning", "update", "messageAction", "sidebar", "terminalCursor", ] as const) { expect(theme.colors[role]).toMatch(/^oklch\(/); } }); it("gamut maps extreme finite OKLCH chroma from theme files", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Extreme chroma", appearance: "light", colors: { accent: "oklch(0.5 1e303 0)" }, }); expect(theme.colors.accent).toBe("oklch(0.5 1e+303 0)"); expect(themeColorToHex(theme.colors.accent)).toBe("#b5005e"); }); it("rejects unknown roles and invalid color values", () => { expect(() => parseThemeFile({ version: THEME_FILE_VERSION, name: "Broken", appearance: "light", colors: { background: "#ffffff" }, }), ).toThrow('"background" is not a supported theme color role.'); expect(() => parseThemeFile({ version: THEME_FILE_VERSION, name: "Broken", appearance: "light", colors: { accent: "var(--danger)" }, }), ).toThrow('The color for "accent" must be a literal CSS color'); }); it("canonicalizes the explicitly exported theme", () => { const serialized = serializeThemeFile({ ...T3_CHAT_THEME, colors: { ...T3_CHAT_THEME.colors, accent: "hsl(263 70% 58%)" }, }); expect(JSON.parse(serialized)).toMatchObject({ version: THEME_FILE_VERSION, id: T3_CHAT_THEME.id, name: T3_CHAT_THEME.label, appearance: "light", colors: { accent: canonical("hsl(263 70% 58%)") }, }); }); it("serializes a theme back into the importable file shape", () => { const theme = { ...parseThemeFile({ version: THEME_FILE_VERSION, id: "community-demo", name: "Community Demo", appearance: "dark", colors: { canvas: "#111111" }, }), collection: { id: "open-vsx:demo.theme", label: "Demo Theme" }, }; const serialized = serializeThemeFile(theme); expect(JSON.parse(serialized)).toMatchObject({ version: THEME_FILE_VERSION, id: theme.id, name: theme.label, appearance: "dark", collection: theme.collection, }); expect(parseThemeFile(JSON.parse(serialized)).collection).toEqual(theme.collection); }); it("keeps sidebar artwork disabled for custom theme files", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Art sidebar", appearance: "light", colors: { accent: "#5b6cff" }, sidebarArtwork: true, }); expect(theme.sidebarArtwork).toBeUndefined(); expect(JSON.parse(serializeThemeFile(theme))).not.toHaveProperty("sidebarArtwork"); }); it("suppresses sidebar artwork during a live custom-theme preview", () => { const listener = vi.fn(); const unsubscribe = subscribeToThemePreview(listener); vi.stubGlobal("document", { documentElement: { classList: { toggle: vi.fn() }, dataset: {}, style: { removeProperty: vi.fn(), setProperty: vi.fn() }, }, }); applyThemeColorPreview(T3_CHAT_THEME.colors, "light"); expect(getThemePreviewSidebarArtwork()).toBe(false); expect(listener).toHaveBeenCalledTimes(1); applyThemePalette("system"); expect(getThemePreviewSidebarArtwork()).toBeNull(); expect(listener).toHaveBeenCalledTimes(2); unsubscribe(); vi.unstubAllGlobals(); }); it("keeps optional light and dark palettes under one theme id", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, id: "aurora", name: "Aurora", appearance: "light", colors: { canvas: "#f8fbff", text: "#10243d" }, variants: { dark: { canvas: "#101827", text: "#eef5ff" }, }, }); expect(getThemeModes(theme)).toEqual(["light", "dark"]); expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ canvas: canonical("#101827"), text: canonical("#eef5ff"), }); expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]); expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark"); expect(resolveDesktopTheme(T3_CHAT_THEME.id, true)).toBe("system"); expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark"); expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark"); expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({ canvas: canonical("#101827"), text: canonical("#eef5ff"), }); }); it("keeps the T3 Chat palette faithful and readable", () => { expectThemeColors(T3_CHAT_THEME.colors, { canvas: "#fdf7fd", chrome: "#fdf7fd", toolbarBorder: "#efbdeb", toolbarControl: "#f3e6f5", toolbarControlHover: "#eccfe3", surfaceRaised: "#fdfafd", input: "#e7c1dc", focus: "#db2777", messageSurface: "#f7def2", codeBackground: "#f5ecf9", codeForeground: "#673c8b", accentSurface: "#f3e6f5", sidebar: "#f2e1f4", }); expectThemeColors(T3_CHAT_THEME.variants!.dark!, { canvas: "#1f1a24", chrome: "#1f1a24", surface: "#29232d", surfaceRaised: "#2c2631", input: "#302029", focus: "#db2777", messageSurface: "#2b2431", codeBackground: "#1f1a24", sidebar: "#171018", sidebarBorder: "#322028", }); for (const mode of ["light", "dark"] as const) { const colors = getThemeColorsForMode(T3_CHAT_THEME, mode)!; expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7); expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.messageForeground, colors.messageSurface)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.secondaryForeground, colors.secondary)).toBeGreaterThanOrEqual( 4.5, ); expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5); } }); it("includes the dual-mode maintainer themes", () => { for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) { expect(getThemeDefinition(theme.id)).toBe(theme); expect(getThemeModes(theme)).toEqual(["light", "dark"]); expect(theme.sidebarArtwork).toBe(true); expect(themeAllowsSidebarArtwork(theme.id)).toBe(true); expect(theme.colors.accent).toMatch(/^oklch\(/); expect(theme.variants?.dark?.accent).toMatch(/^oklch\(/); for (const mode of ["light", "dark"] as const) { const colors = getThemeColorsForMode(theme, mode); expect(colors).not.toBeNull(); expect(contrastRatio(colors!.text, colors!.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeGreaterThanOrEqual(4.5); if (theme !== T3_CHAT_THEME) { expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeLessThan(5.5); expect(contrastRatio(colors!.textMuted, colors!.canvas)).toBeCloseTo( mode === "dark" ? 5.082 : 4.705, 1, ); } expect(contrastRatio(colors!.accentForeground, colors!.accent)).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors!.toolbarControlForeground, colors!.toolbarControl), ).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors!.messageForeground, colors!.messageSurface), ).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors!.messageActionForeground, colors!.messageAction), ).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors!.messageActionForeground, colors!.messageActionHover), ).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors!.mutedForeground, colors!.muted)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors!.placeholder, colors!.surfaceRaised)).toBeGreaterThanOrEqual( 4.5, ); } } expect(themeAllowsSidebarArtwork("my-custom-theme")).toBe(false); }); it("rejects a variant that repeats the base appearance", () => { expect(() => parseThemeFile({ version: THEME_FILE_VERSION, name: "Duplicate light", appearance: "light", colors: { canvas: "#f8fbff" }, variants: { light: { canvas: "#101827" } }, }), ).toThrow('Theme variants must not repeat the base appearance "light".'); }); it("keeps a single-mode theme on its only palette", () => { const theme = parseThemeFile({ version: THEME_FILE_VERSION, id: "midnight-slate", name: "Midnight Slate", appearance: "dark", colors: { canvas: "#111827", messageAction: "#2563eb" }, }); expect(getThemeModes(theme)).toEqual(["dark"]); expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ canvas: canonical("#111827"), }); expect(getThemeColorsForMode(theme, "light")).toBeNull(); }); it("invalidates cached themes when another tab clears localStorage", () => {