1075 lines · 35.8 KB · TypeScript
RawDownload
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.