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

+2
View File
@@ -14,6 +14,8 @@
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
- A theme that no longer exists shows as System instead of an unstyled page
## 26.10.2
+7
View File
@@ -35,6 +35,13 @@
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
"defaultLook": true
},
{
"id": "classic-themes",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
"sha256": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
"defaultLook": true
},
{
"id": "docker",
"source": "tmxplug",
+2
View File
@@ -13,6 +13,8 @@ Plugin API 1.4.0.
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
- `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release
- `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
- A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend
- `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend
### Fixed
+11
View File
@@ -563,6 +563,17 @@ inside `@media` or `@supports`. `@import`, `url()` to another host and other at-
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
`--foreground`, `--card`, `--border`, `--muted` and the rest).
A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
`validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
`validateThemeCss()`. Notes:
- A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
- The first theme a bundled default-look plugin lists is the default for new installs.
- Themes show in the picker in the order core lists its own, then the plugin's order.
- A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
- Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
- Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example.
## Looking native
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
+36 -22
View File
@@ -5,7 +5,12 @@ import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
import {
readManifest,
resolveEntry,
copyDir,
isThemeOnly,
} from "../lib/plugin-dir.mjs";
import { openapi } from "./openapi.mjs";
import { splitByCoreClasses } from "../lib/css-classes.mjs";
@@ -232,32 +237,41 @@ export async function build({ cwd }) {
fs.mkdirSync(outDir, { recursive: true });
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
if (!backendEntry) {
if (!backendEntry && !isThemeOnly(manifest)) {
throw new Error(
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
);
}
await esbuild.build({
entryPoints: [backendEntry],
outfile: path.join(outDir, "backend.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
// A native dependency's compiled .node binary is resolved by the
// package's own relative paths, which break once esbuild inlines its JS
// elsewhere. Declaring it in nativeDependencies keeps it a real
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
loader: BINARY_LOADERS,
// Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one.
banner: { js: REQUIRE_BANNER },
});
if (backendEntry) {
await esbuild.build({
entryPoints: [backendEntry],
outfile: path.join(outDir, "backend.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
// A native dependency's compiled .node binary is resolved by the
// package's own relative paths, which break once esbuild inlines its JS
// elsewhere. Declaring it in nativeDependencies keeps it a real
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
loader: BINARY_LOADERS,
// Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one.
banner: { js: REQUIRE_BANNER },
});
} else {
// Core loads every plugin through a backend entry, so a theme only
// plugin gets an empty one.
fs.writeFileSync(
path.join(outDir, "backend.js"),
"export function activate() {}\n",
);
}
for (const worker of readWorkerEntries(cwd, pluginId)) {
await esbuild.build({
+37 -2
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
@@ -25,7 +25,8 @@ export async function validate({ cwd }) {
// The manifest names files. They have to be there.
const referenced = [
raw.backend ?? "dist/backend.js",
raw.frontend ?? "dist/frontend.js",
// A theme only plugin has no frontend code.
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
raw.locales ?? "locales",
];
for (const rel of referenced) {
@@ -35,6 +36,7 @@ export async function validate({ cwd }) {
}
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
problems.push(...validateLookCapabilities(cwd, raw));
problems.push(
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
);
@@ -78,6 +80,39 @@ export function validateThemeFiles(cwd, raw, validateThemeCss) {
return problems;
}
/**
* ui:layout and ui:theme must match what the manifest contributes, both
* ways. The manifest check already flags a contribution without its
* capability; this flags a capability with nothing behind it, and a layout
* with no frontend to register it.
*/
export function validateLookCapabilities(cwd, raw) {
const problems = [];
const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
const layouts = raw?.contributes?.layouts;
const themes = raw?.contributes?.themes;
const hasLayouts = Array.isArray(layouts) && layouts.length > 0;
const hasThemes = Array.isArray(themes) && themes.length > 0;
if (capabilities.includes("ui:layout") && !hasLayouts) {
problems.push('"ui:layout" is declared but contributes.layouts is empty');
}
if (capabilities.includes("ui:theme") && !hasThemes) {
problems.push('"ui:theme" is declared but contributes.themes is empty');
}
if (hasLayouts) {
const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"];
const frontend = entries.some((entry) =>
fs.existsSync(path.join(cwd, "src", "frontend", entry)),
);
if (!frontend && raw.frontend === undefined) {
problems.push(
"contributes.layouts needs a frontend that calls app.registerLayout",
);
}
}
return problems;
}
/** Env vars core sets for everyone, so a plugin need not list them. */
const CORE_ENV = new Set([
"DATA_DIR",
@@ -14,6 +14,17 @@ export function readManifest(cwd) {
}
/** The first of these that exists, or null. */
/** Only themes: CSS files and no code, so no source entry is needed. */
export function isThemeOnly(manifest) {
const capabilities = manifest.capabilities ?? [];
return (
capabilities.length > 0 &&
capabilities.every((capability) => capability === "ui:theme") &&
Array.isArray(manifest.contributes?.themes) &&
manifest.contributes.themes.length > 0
);
}
export function resolveEntry(cwd, candidates) {
for (const candidate of candidates) {
const full = path.join(cwd, candidate);
+1
View File
@@ -34,6 +34,7 @@ const OFFICIAL_IDS = [
"alerts",
"automations",
"classic-layout",
"classic-themes",
"docker",
"file-manager",
"fleets",
+36
View File
@@ -35,6 +35,42 @@ function fixturePlugin(): string {
}
describe("termix-plugin build", () => {
it("builds a theme only plugin with an empty backend and no source", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
fs.writeFileSync(
path.join(dir, "manifest.json"),
JSON.stringify({
id: "theme-fixture",
capabilities: ["ui:theme"],
contributes: { themes: [{ id: "paper" }] },
}),
);
await build({ cwd: dir });
expect(
fs.readFileSync(path.join(dir, "dist", "backend.js"), "utf8"),
).toContain("export function activate()");
expect(fs.existsSync(path.join(dir, "dist", "frontend.js"))).toBe(false);
});
it("still needs a backend entry once a plugin holds more than themes", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
fs.writeFileSync(
path.join(dir, "manifest.json"),
JSON.stringify({
id: "mixed-fixture",
capabilities: ["ui:theme", "ui:layout"],
contributes: { themes: [{ id: "paper" }] },
}),
);
await expect(build({ cwd: dir })).rejects.toThrow("no backend entry");
});
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
+45
View File
@@ -99,3 +99,48 @@ describe("termix-plugin validate themes", () => {
expect(output).not.toContain("theme paper");
});
});
describe("termix-plugin validate layouts and capabilities", () => {
function withManifest(extra: Record<string, unknown>) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({ id: "fixture", version: "1.0.0", ...extra }),
);
return root;
}
it("flags ui:theme with no themes and ui:layout with no layouts", async () => {
const output = await problems(
withManifest({ capabilities: ["ui:theme", "ui:layout"] }),
);
expect(output).toContain(
'"ui:theme" is declared but contributes.themes is empty',
);
expect(output).toContain(
'"ui:layout" is declared but contributes.layouts is empty',
);
});
it("flags a layout plugin with no frontend source", async () => {
const output = await problems(
withManifest({
capabilities: ["ui:layout"],
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
}),
);
expect(output).toContain("contributes.layouts needs a frontend");
});
it("accepts a layout plugin that has a frontend entry", async () => {
const root = withManifest({
capabilities: ["ui:layout"],
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
});
fs.mkdirSync(path.join(root, "src", "frontend"), { recursive: true });
fs.writeFileSync(path.join(root, "src", "frontend", "index.tsx"), "");
const output = await problems(root);
expect(output).not.toContain("contributes.layouts needs a frontend");
expect(output).not.toContain("is declared but");
});
});
+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);
});
});