From b414b85746d97ddcfc98c5d9fcf833cd9ac8d415 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 18 Oct 2026 17:56:34 -0500 Subject: [PATCH] feat: move color themes into a bundled themes plugin --- CHANGELOG.md | 2 + docker/bundled-plugins.json | 7 + packages/plugin-sdk/CHANGELOG.md | 2 + packages/plugin-sdk/PLUGIN-GUIDE.md | 11 + packages/plugin-sdk/cli/commands/build.mjs | 58 ++-- packages/plugin-sdk/cli/commands/validate.mjs | 39 ++- packages/plugin-sdk/cli/lib/plugin-dir.mjs | 11 + scripts/check-core-plugin-ids.cjs | 1 + scripts/plugin-build.test.ts | 36 +++ scripts/plugin-validate.test.ts | 45 +++ src/ui/components/theme-provider.tsx | 13 +- src/ui/index.css | 282 ------------------ src/ui/locales/en.json | 9 +- src/ui/look/theme-registry.ts | 66 ++-- .../tests/components/theme-provider.test.tsx | 32 ++ src/ui/tests/look/LookSettings.test.tsx | 4 +- src/ui/tests/look/RepairView.test.tsx | 4 +- src/ui/tests/look/theme-registry.test.ts | 72 +++-- src/ui/tests/plugin-host/bridge.test.ts | 3 +- .../appearance/appearance-controls.test.tsx | 14 +- 20 files changed, 305 insertions(+), 406 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40cfd28d4..bd584a29c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docker/bundled-plugins.json b/docker/bundled-plugins.json index 230b50d6b..691a75b0e 100644 --- a/docker/bundled-plugins.json +++ b/docker/bundled-plugins.json @@ -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", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 6f872e8de..01bcd3746 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -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 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 03c6ba420..c1120546d 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -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 diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index 3d84a2eff..b9d754180 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -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({ diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs index b45dc6584..58c069339 100644 --- a/packages/plugin-sdk/cli/commands/validate.mjs +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -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", diff --git a/packages/plugin-sdk/cli/lib/plugin-dir.mjs b/packages/plugin-sdk/cli/lib/plugin-dir.mjs index 427543c8f..93920d78b 100644 --- a/packages/plugin-sdk/cli/lib/plugin-dir.mjs +++ b/packages/plugin-sdk/cli/lib/plugin-dir.mjs @@ -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); diff --git a/scripts/check-core-plugin-ids.cjs b/scripts/check-core-plugin-ids.cjs index 4ea6a133d..1cb66cbba 100644 --- a/scripts/check-core-plugin-ids.cjs +++ b/scripts/check-core-plugin-ids.cjs @@ -34,6 +34,7 @@ const OFFICIAL_IDS = [ "alerts", "automations", "classic-layout", + "classic-themes", "docker", "file-manager", "fleets", diff --git a/scripts/plugin-build.test.ts b/scripts/plugin-build.test.ts index ec71c9361..3f8422d64 100644 --- a/scripts/plugin-build.test.ts +++ b/scripts/plugin-build.test.ts @@ -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(); diff --git a/scripts/plugin-validate.test.ts b/scripts/plugin-validate.test.ts index 3d1f5998b..16e859956 100644 --- a/scripts/plugin-validate.test.ts +++ b/scripts/plugin-validate.test.ts @@ -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) { + 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"); + }); +}); diff --git a/src/ui/components/theme-provider.tsx b/src/ui/components/theme-provider.tsx index 3bd5a3c48..6039f6efe 100644 --- a/src/ui/components/theme-provider.tsx +++ b/src/ui/components/theme-provider.tsx @@ -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); diff --git a/src/ui/index.css b/src/ui/index.css index cf65a150d..9be5e16a9 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -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" diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index ed314fc1d..dc07667dc 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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" } } }, diff --git a/src/ui/look/theme-registry.ts b/src/ui/look/theme-registry.ts index fb563093c..5ba785689 100644 --- a/src/ui/look/theme-registry.ts +++ b/src/ui/look/theme-registry.ts @@ -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 { diff --git a/src/ui/tests/components/theme-provider.test.tsx b/src/ui/tests/components/theme-provider.test.tsx index 6cb2e5d51..46b1116f4 100644 --- a/src/ui/tests/components/theme-provider.test.tsx +++ b/src/ui/tests/components/theme-provider.test.tsx @@ -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"]); + }); }); diff --git a/src/ui/tests/look/LookSettings.test.tsx b/src/ui/tests/look/LookSettings.test.tsx index a6c1203d0..d72054aa7 100644 --- a/src/ui/tests/look/LookSettings.test.tsx +++ b/src/ui/tests/look/LookSettings.test.tsx @@ -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" }); }); }); diff --git a/src/ui/tests/look/RepairView.test.tsx b/src/ui/tests/look/RepairView.test.tsx index 311bf01f5..b2ef96136 100644 --- a/src/ui/tests/look/RepairView.test.tsx +++ b/src/ui/tests/look/RepairView.test.tsx @@ -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 () => { diff --git a/src/ui/tests/look/theme-registry.test.ts b/src/ui/tests/look/theme-registry.test.ts index 4a4f0f517..1ccf07c89 100644 --- a/src/ui/tests/look/theme-registry.test.ts +++ b/src/ui/tests/look/theme-registry.test.ts @@ -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", () => { diff --git a/src/ui/tests/plugin-host/bridge.test.ts b/src/ui/tests/plugin-host/bridge.test.ts index c4fda23fe..33360892a 100644 --- a/src/ui/tests/plugin-host/bridge.test.ts +++ b/src/ui/tests/plugin-host/bridge.test.ts @@ -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"); }); diff --git a/src/ui/tests/settings/appearance/appearance-controls.test.tsx b/src/ui/tests/settings/appearance/appearance-controls.test.tsx index d76c7e7cd..a931d9f31 100644 --- a/src/ui/tests/settings/appearance/appearance-controls.test.tsx +++ b/src/ui/tests/settings/appearance/appearance-controls.test.tsx @@ -95,17 +95,17 @@ describe("ThemeGrid", () => { render(); 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(); - 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); }); });