feat: move color themes into a bundled themes plugin

This commit is contained in:
LukeGus committed 2026-10-18 17:56:34 -05:00
1 parent d049588f7f
commit b414b85746
20 files changed
+305 -406

No files matched your search

+10 -3
View File
@@ -1,7 +1,12 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useState } from "react";
import type { ThemeId } from "@/types/ui-types";
import { SYSTEM_THEME, getTheme, useThemes } from "@/look/theme-registry";
import {
SYSTEM_THEME,
effectiveThemeId,
getTheme,
useThemes,
} from "@/look/theme-registry";
import { applyThemeCss } from "@/look/theme-css";
type ThemeProviderProps = {
@@ -41,7 +46,9 @@ export function ThemeProvider({
// Plugin themes arrive after login; the class and CSS follow them.
const themes = useThemes();
const activeTheme = previewTheme ?? theme;
// A saved theme whose plugin is gone shows as System.
const shownTheme = effectiveThemeId(theme);
const activeTheme = previewTheme ?? shownTheme;
useEffect(() => {
const root = window.document.documentElement;
@@ -60,7 +67,7 @@ export function ThemeProvider({
}, [activeTheme, themes]);
const value = {
theme,
theme: shownTheme,
setTheme: (theme: ThemeId, persist = true) => {
if (persist) localStorage.setItem(storageKey, theme);
setTheme(theme);
-282
View File
@@ -136,288 +136,6 @@
--terminal-bg: #0c0d0b;
}
/* ── Dracula ────────────────────────────────────────────────── */
.dracula {
--background: #282a36;
--foreground: #f8f8f2;
--card: #2d303d;
--card-foreground: #f8f8f2;
--popover: #2d303d;
--popover-foreground: #f8f8f2;
--primary: #8be9fd;
--primary-foreground: #282a36;
--secondary: #50536b;
--secondary-foreground: #f8f8f2;
--surface: #2d303d;
--surface-dim: #21222c;
--muted: #50536b;
--muted-foreground: #a8b3d9;
--accent: #5a5e7a;
--accent-foreground: #f8f8f2;
--destructive: #ff5555;
--border: rgba(168, 179, 217, 0.18);
--input: rgba(168, 179, 217, 0.14);
--ring: #8be9fd;
--chart-1: #ff79c6;
--chart-2: #8be9fd;
--chart-3: #50fa7b;
--chart-4: #ffb86c;
--chart-5: #bd93f9;
--radius: 0.625rem;
--sidebar: #21222c;
--sidebar-foreground: #f8f8f2;
--sidebar-primary: #8be9fd;
--sidebar-primary-foreground: #282a36;
--sidebar-accent: #5a5e7a;
--sidebar-accent-foreground: #f8f8f2;
--sidebar-border: rgba(168, 179, 217, 0.18);
--sidebar-ring: #8be9fd;
--warning: #f1fa8c;
--terminal-bg: #282a36;
}
/* ── Catppuccin Mocha ───────────────────────────────────────── */
.catppuccin {
--background: #1e1e2e;
--foreground: #cdd6f4;
--card: #202235;
--card-foreground: #cdd6f4;
--popover: #202235;
--popover-foreground: #cdd6f4;
--primary: #89b4fa;
--primary-foreground: #1e1e2e;
--secondary: #3b3d52;
--secondary-foreground: #cdd6f4;
--surface: #202235;
--surface-dim: #181825;
--muted: #3b3d52;
--muted-foreground: #b8c0e0;
--accent: #45475a;
--accent-foreground: #cdd6f4;
--destructive: #f38ba8;
--border: rgba(186, 194, 222, 0.18);
--input: rgba(186, 194, 222, 0.14);
--ring: #89b4fa;
--chart-1: #f38ba8;
--chart-2: #89dceb;
--chart-3: #a6e3a1;
--chart-4: #fab387;
--chart-5: #cba6f7;
--radius: 0.625rem;
--sidebar: #181825;
--sidebar-foreground: #cdd6f4;
--sidebar-primary: #89b4fa;
--sidebar-primary-foreground: #1e1e2e;
--sidebar-accent: #45475a;
--sidebar-accent-foreground: #cdd6f4;
--sidebar-border: rgba(186, 194, 222, 0.18);
--sidebar-ring: #89b4fa;
--warning: #f9e2af;
--terminal-bg: #1e1e2e;
}
/* ── Nord ───────────────────────────────────────────────────── */
.nord {
--background: #2e3440;
--foreground: #eceff4;
--card: #353c4a;
--card-foreground: #eceff4;
--popover: #353c4a;
--popover-foreground: #eceff4;
--primary: #88c0d0;
--primary-foreground: #2e3440;
--secondary: #434c5e;
--secondary-foreground: #eceff4;
--surface: #353c4a;
--surface-dim: #2a303b;
--muted: #434c5e;
--muted-foreground: #b4c0d4;
--accent: #4c566a;
--accent-foreground: #eceff4;
--destructive: #bf616a;
--border: rgba(180, 192, 212, 0.18);
--input: rgba(180, 192, 212, 0.14);
--ring: #88c0d0;
--chart-1: #bf616a;
--chart-2: #88c0d0;
--chart-3: #a3be8c;
--chart-4: #ebcb8b;
--chart-5: #b48ead;
--radius: 0.625rem;
--sidebar: #2a303b;
--sidebar-foreground: #eceff4;
--sidebar-primary: #88c0d0;
--sidebar-primary-foreground: #2e3440;
--sidebar-accent: #4c566a;
--sidebar-accent-foreground: #eceff4;
--sidebar-border: rgba(180, 192, 212, 0.18);
--sidebar-ring: #88c0d0;
--warning: #ebcb8b;
--terminal-bg: #2e3440;
}
/* ── Solarized Dark ─────────────────────────────────────────── */
.solarized {
--background: #002b36;
--foreground: #b7c4c4;
--card: #073642;
--card-foreground: #b7c4c4;
--popover: #073642;
--popover-foreground: #b7c4c4;
--primary: #268bd2;
--primary-foreground: #002b36;
--secondary: #0b4654;
--secondary-foreground: #b7c4c4;
--surface: #073642;
--surface-dim: #00212b;
--muted: #0b4654;
--muted-foreground: #9fb3b3;
--accent: #105667;
--accent-foreground: #d5dddd;
--destructive: #dc322f;
--border: rgba(159, 179, 179, 0.2);
--input: rgba(159, 179, 179, 0.16);
--ring: #268bd2;
--chart-1: #dc322f;
--chart-2: #2aa198;
--chart-3: #859900;
--chart-4: #b58900;
--chart-5: #6c71c4;
--radius: 0.625rem;
--sidebar: #073642;
--sidebar-foreground: #b7c4c4;
--sidebar-primary: #268bd2;
--sidebar-primary-foreground: #002b36;
--sidebar-accent: #105667;
--sidebar-accent-foreground: #d5dddd;
--sidebar-border: rgba(159, 179, 179, 0.2);
--sidebar-ring: #268bd2;
--warning: #b58900;
--terminal-bg: #002b36;
}
/* ── Tokyo Night ────────────────────────────────────────────── */
.tokyo-night {
--background: #1a1b26;
--foreground: #c0caf5;
--card: #1f2335;
--card-foreground: #c0caf5;
--popover: #1f2335;
--popover-foreground: #c0caf5;
--primary: #7aa2f7;
--primary-foreground: #1a1b26;
--secondary: #2a2f47;
--secondary-foreground: #c0caf5;
--surface: #1f2335;
--surface-dim: #16161e;
--muted: #2a2f47;
--muted-foreground: #8b93bd;
--accent: #2f3654;
--accent-foreground: #c0caf5;
--destructive: #f7768e;
--border: rgba(139, 147, 189, 0.2);
--input: rgba(139, 147, 189, 0.16);
--ring: #7aa2f7;
--chart-1: #f7768e;
--chart-2: #7dcfff;
--chart-3: #9ece6a;
--chart-4: #e0af68;
--chart-5: #bb9af7;
--radius: 0.625rem;
--sidebar: #16161e;
--sidebar-foreground: #c0caf5;
--sidebar-primary: #7aa2f7;
--sidebar-primary-foreground: #1a1b26;
--sidebar-accent: #2f3654;
--sidebar-accent-foreground: #c0caf5;
--sidebar-border: rgba(139, 147, 189, 0.2);
--sidebar-ring: #7aa2f7;
--warning: #e0af68;
--terminal-bg: #1a1b26;
}
/* ── One Dark ───────────────────────────────────────────────── */
.one-dark {
--background: #282c34;
--foreground: #abb2bf;
--card: #2f343d;
--card-foreground: #abb2bf;
--popover: #2f343d;
--popover-foreground: #abb2bf;
--primary: #61afef;
--primary-foreground: #282c34;
--secondary: #3a404b;
--secondary-foreground: #abb2bf;
--surface: #2f343d;
--surface-dim: #21252b;
--muted: #3a404b;
--muted-foreground: #9aa3b2;
--accent: #424957;
--accent-foreground: #abb2bf;
--destructive: #e06c75;
--border: rgba(154, 163, 178, 0.18);
--input: rgba(154, 163, 178, 0.14);
--ring: #61afef;
--chart-1: #e06c75;
--chart-2: #56b6c2;
--chart-3: #98c379;
--chart-4: #e5c07b;
--chart-5: #c678dd;
--radius: 0.625rem;
--sidebar: #21252b;
--sidebar-foreground: #abb2bf;
--sidebar-primary: #61afef;
--sidebar-primary-foreground: #282c34;
--sidebar-accent: #424957;
--sidebar-accent-foreground: #abb2bf;
--sidebar-border: rgba(154, 163, 178, 0.18);
--sidebar-ring: #61afef;
--warning: #e5c07b;
--terminal-bg: #282c34;
}
/* ── Gruvbox Dark ───────────────────────────────────────────── */
.gruvbox {
--background: #282828;
--foreground: #ebdbb2;
--card: #32302f;
--card-foreground: #ebdbb2;
--popover: #32302f;
--popover-foreground: #ebdbb2;
--primary: #fabd2f;
--primary-foreground: #282828;
--secondary: #45403d;
--secondary-foreground: #ebdbb2;
--surface: #32302f;
--surface-dim: #1d2021;
--muted: #45403d;
--muted-foreground: #c4b59d;
--accent: #504945;
--accent-foreground: #ebdbb2;
--destructive: #fb4934;
--border: rgba(196, 181, 157, 0.18);
--input: rgba(196, 181, 157, 0.14);
--ring: #fabd2f;
--chart-1: #fb4934;
--chart-2: #8ec07c;
--chart-3: #b8bb26;
--chart-4: #fabd2f;
--chart-5: #d3869b;
--radius: 0.625rem;
--sidebar: #1d2021;
--sidebar-foreground: #ebdbb2;
--sidebar-primary: #fabd2f;
--sidebar-primary-foreground: #282828;
--sidebar-accent: #504945;
--sidebar-accent-foreground: #ebdbb2;
--sidebar-border: rgba(196, 181, 157, 0.18);
--sidebar-ring: #fabd2f;
--warning: #fabd2f;
--terminal-bg: #282828;
}
@layer base {
* {
/* Tailwind v4 defaults border-color to currentColor, so any bare "border"
+1 -8
View File
@@ -1115,14 +1115,7 @@
"colorPickerTooltip": "Open color picker",
"themeSystem": "System",
"themeLight": "Light",
"themeDark": "Dark",
"themeDracula": "Dracula",
"themeCatppuccin": "Catppuccin",
"themeNord": "Nord",
"themeSolarized": "Solarized",
"themeTokyoNight": "Tokyo Night",
"themeOneDark": "One Dark",
"themeGruvbox": "Gruvbox"
"themeDark": "Dark"
}
}
},
+17 -49
View File
@@ -24,8 +24,9 @@ export interface ThemeDef {
}
/**
* Themes core ships, with their tokens in index.css. A plugin theme with the
* same id takes the built-in's place while it is installed.
* The two themes core keeps as a fallback, with their tokens in index.css.
* The rest come from theme plugins. A plugin theme with the same id takes
* the built-in's place while it is installed.
*/
export const BUILTIN_THEMES: readonly ThemeDef[] = [
{
@@ -40,48 +41,6 @@ export const BUILTIN_THEMES: readonly ThemeDef[] = [
mode: "dark",
swatch: { background: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
},
{
id: "dracula",
label: "newUi.sidebar.userProfile.themeDracula",
mode: "dark",
swatch: { background: "#282a36", panel: "#44475a", text: "#f8f8f2" },
},
{
id: "catppuccin",
label: "newUi.sidebar.userProfile.themeCatppuccin",
mode: "dark",
swatch: { background: "#1e1e2e", panel: "#313244", text: "#cdd6f4" },
},
{
id: "nord",
label: "newUi.sidebar.userProfile.themeNord",
mode: "dark",
swatch: { background: "#2e3440", panel: "#3b4252", text: "#eceff4" },
},
{
id: "solarized",
label: "newUi.sidebar.userProfile.themeSolarized",
mode: "dark",
swatch: { background: "#002b36", panel: "#073642", text: "#93a1a1" },
},
{
id: "tokyo-night",
label: "newUi.sidebar.userProfile.themeTokyoNight",
mode: "dark",
swatch: { background: "#1a1b26", panel: "#24283b", text: "#c0caf5" },
},
{
id: "one-dark",
label: "newUi.sidebar.userProfile.themeOneDark",
mode: "dark",
swatch: { background: "#282c34", panel: "#21252b", text: "#abb2bf" },
},
{
id: "gruvbox",
label: "newUi.sidebar.userProfile.themeGruvbox",
mode: "dark",
swatch: { background: "#282828", panel: "#3c3836", text: "#ebdbb2" },
},
];
const builtins = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
@@ -96,15 +55,15 @@ function emit() {
for (const listener of listeners) listener();
}
/** Built-ins in their order, a plugin theme in a built-in's place, then the rest. */
/** Built-ins in their order, a plugin theme in a built-in's place, then the rest as declared. */
export function listThemes(): ThemeDef[] {
if (!snapshot) {
const replaced = BUILTIN_THEMES.map(
(theme) => pluginThemes.get(theme.id) ?? theme,
);
const extra = [...pluginThemes.values()]
.filter((theme) => !builtins.has(theme.id))
.sort((a, b) => a.id.localeCompare(b.id));
const extra = [...pluginThemes.values()].filter(
(theme) => !builtins.has(theme.id),
);
snapshot = [...replaced, ...extra];
}
return snapshot;
@@ -154,7 +113,16 @@ export function themeMode(id: string): "light" | "dark" {
? "dark"
: "light";
}
return getTheme(id)?.mode ?? "dark";
const theme = getTheme(id);
if (theme) return theme.mode;
// A theme that went away follows the OS, once the plugins have said so.
return pluginThemesKnown ? themeMode(SYSTEM_THEME) : "dark";
}
/** The theme to show for a saved id: itself, or System when it is gone. */
export function effectiveThemeId(id: string): string {
if (id === SYSTEM_THEME || getTheme(id) || !pluginThemesKnown) return id;
return SYSTEM_THEME;
}
export function themeLabel(theme: ThemeDef, t: TFunction): string {
@@ -75,4 +75,36 @@ describe("ThemeProvider", () => {
expect(classes()).toEqual(["dark"]);
expect(document.getElementById("termix-theme-css")).toBeNull();
});
it("shows system when the saved theme's plugin is gone", () => {
localStorage.setItem("test-theme", "nord");
mount();
// Plugins not read yet, so the saved theme is kept.
expect(classes()).toEqual(["nord"]);
act(() => setPluginThemes([]));
expect(api!.theme).toBe("system");
expect(classes()).toHaveLength(1);
expect(["light", "dark"]).toContain(classes()[0]);
expect(localStorage.getItem("test-theme")).toBe("nord");
});
it("brings the saved theme back when its plugin returns", () => {
localStorage.setItem("test-theme", "nord");
mount();
act(() => setPluginThemes([]));
act(() =>
setPluginThemes([
{
id: "nord",
label: "theme.nord",
mode: "dark",
swatch: { background: "#2e3440", panel: "#3b4252", text: "#eceff4" },
pluginId: "classic-themes",
cssHref: "/plugin-assets/classic-themes/_themes/nord.css?v=1",
},
]),
);
expect(api!.theme).toBe("nord");
expect(classes()).toEqual(["nord"]);
});
});
+2 -2
View File
@@ -60,7 +60,7 @@ describe("LookSettings", () => {
const [, layoutDefault, themeDefault] = screen.getAllByRole("combobox");
fireEvent.change(layoutDefault, { target: { value: "fancy" } });
expect(look.chooseDefaultLook).toHaveBeenCalledWith({ layout: "fancy" });
fireEvent.change(themeDefault, { target: { value: "nord" } });
expect(look.chooseDefaultLook).toHaveBeenCalledWith({ theme: "nord" });
fireEvent.change(themeDefault, { target: { value: "light" } });
expect(look.chooseDefaultLook).toHaveBeenCalledWith({ theme: "light" });
});
});
+2 -2
View File
@@ -155,9 +155,9 @@ describe("RepairView", () => {
fireEvent.click(screen.getByText("Classic"));
expect(look.chooseLayout).toHaveBeenCalledWith("classic");
fireEvent.click(
screen.getAllByTitle("newUi.sidebar.userProfile.themeNord")[0],
screen.getAllByTitle("newUi.sidebar.userProfile.themeLight")[0],
);
expect(look.chooseTheme).toHaveBeenCalledWith("nord");
expect(look.chooseTheme).toHaveBeenCalledWith("light");
});
it("resets to the default look", async () => {
+44 -28
View File
@@ -2,65 +2,81 @@ import { afterEach, describe, expect, it } from "vitest";
import {
BUILTIN_THEMES,
arePluginThemesKnown,
effectiveThemeId,
getTheme,
listThemes,
resetPluginThemes,
setPluginThemes,
themeLabel,
themeMode,
type ThemeDef,
} from "@/look/theme-registry";
afterEach(() => resetPluginThemes());
const swatch = { background: "#000000", panel: "#111111", text: "#ffffff" };
function pluginTheme(
id: string,
mode: "light" | "dark" = "dark",
pluginId = "more-themes",
): ThemeDef {
return { id, label: `theme.${id}`, mode, swatch, pluginId };
}
describe("theme registry", () => {
it("lists the built-ins in their order", () => {
expect(listThemes().map((theme) => theme.id)).toEqual(
BUILTIN_THEMES.map((theme) => theme.id),
);
it("keeps only a light and a dark theme in core", () => {
expect(BUILTIN_THEMES.map((theme) => theme.id)).toEqual(["light", "dark"]);
expect(listThemes().map((theme) => theme.id)).toEqual(["light", "dark"]);
expect(arePluginThemesKnown()).toBe(false);
});
it("lets a plugin theme take a built-in's place and adds new ones after", () => {
it("lets a plugin theme take a built-in's place and adds new ones in the order given", () => {
setPluginThemes([
{
id: "nord",
label: "themes.nord",
mode: "dark",
swatch,
pluginId: "more-themes",
cssHref: "/plugin-assets/more-themes/_themes/nord.css",
},
{
id: "paper",
label: "Paper",
mode: "light",
swatch,
pluginId: "more-themes",
...pluginTheme("dark"),
cssHref: "/plugin-assets/more-themes/_themes/dark.css",
},
pluginTheme("nord"),
pluginTheme("dracula"),
pluginTheme("paper", "light"),
]);
const ids = listThemes().map((theme) => theme.id);
expect(ids.indexOf("nord")).toBe(
BUILTIN_THEMES.findIndex((theme) => theme.id === "nord"),
);
expect(ids[ids.length - 1]).toBe("paper");
expect(getTheme("nord")?.pluginId).toBe("more-themes");
expect(ids).toEqual(["light", "dark", "nord", "dracula", "paper"]);
expect(getTheme("dark")?.pluginId).toBe("more-themes");
expect(arePluginThemesKnown()).toBe(true);
setPluginThemes([]);
expect(getTheme("nord")?.pluginId).toBeUndefined();
expect(getTheme("dark")?.pluginId).toBeUndefined();
expect(getTheme("paper")).toBeUndefined();
expect(listThemes().map((theme) => theme.id)).toEqual(["light", "dark"]);
});
it("reports each theme's mode, following the OS for system", () => {
expect(themeMode("light")).toBe("light");
expect(themeMode("dracula")).toBe("dark");
expect(["light", "dark"]).toContain(themeMode("system"));
setPluginThemes([
{ id: "paper", label: "Paper", mode: "light", swatch, pluginId: "x" },
]);
setPluginThemes([pluginTheme("paper", "light"), pluginTheme("nord")]);
expect(themeMode("paper")).toBe("light");
expect(themeMode("nord")).toBe("dark");
});
it("assumes dark for an unknown theme until the plugins have loaded, then follows the OS", () => {
expect(themeMode("nord")).toBe("dark");
setPluginThemes([]);
expect(["light", "dark"]).toContain(themeMode("nord"));
expect(themeMode("nord")).toBe(themeMode("system"));
});
it("falls back to system for a saved theme that no longer exists", () => {
// Plugins not read yet: the id may still arrive, so keep it.
expect(effectiveThemeId("nord")).toBe("nord");
setPluginThemes([pluginTheme("nord")]);
expect(effectiveThemeId("nord")).toBe("nord");
expect(effectiveThemeId("light")).toBe("light");
expect(effectiveThemeId("system")).toBe("system");
setPluginThemes([]);
expect(effectiveThemeId("nord")).toBe("system");
expect(effectiveThemeId("dark")).toBe("dark");
});
it("translates core labels in core and plugin labels in the plugin", () => {
+2 -1
View File
@@ -113,8 +113,9 @@ describe("useTheme", () => {
);
act(() => setTheme!("paper"));
expect(result.current.theme).toBe("light");
// Gone themes follow the OS once the plugin list is known.
act(() => setTheme!("dracula"));
expect(result.current.theme).toBe("dark");
expect(["light", "dark"]).toContain(result.current.theme);
resetPluginThemes();
localStorage.removeItem("bridge-theme");
});
@@ -95,17 +95,17 @@ describe("ThemeGrid", () => {
render(<ThemeGrid value="dark" onChange={onChange} />);
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(THEMES.length);
fireEvent.click(buttons[THEMES.findIndex((t) => t.id === "gruvbox")]);
expect(onChange).toHaveBeenCalledWith("gruvbox");
fireEvent.click(buttons[THEMES.findIndex((t) => t.id === "light")]);
expect(onChange).toHaveBeenCalledWith("light");
});
it("previews a theme while it is hovered", () => {
render(<ThemeGrid value="dark" onChange={vi.fn()} />);
const nord =
screen.getAllByRole("button")[THEMES.findIndex((t) => t.id === "nord")];
fireEvent.mouseEnter(nord);
expect(theme.setThemePreview).toHaveBeenLastCalledWith("nord");
fireEvent.mouseLeave(nord);
const light =
screen.getAllByRole("button")[THEMES.findIndex((t) => t.id === "light")];
fireEvent.mouseEnter(light);
expect(theme.setThemePreview).toHaveBeenLastCalledWith("light");
fireEvent.mouseLeave(light);
expect(theme.setThemePreview).toHaveBeenLastCalledWith(null);
});
});