mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-21 02:51:13 +00:00
feat: move color themes into a bundled themes plugin
This commit is contained in:
1 parent
d049588f7f
commit
b414b85746
20 files changed
+305
-406
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -34,6 +34,7 @@ const OFFICIAL_IDS = [
|
||||
"alerts",
|
||||
"automations",
|
||||
"classic-layout",
|
||||
"classic-themes",
|
||||
"docker",
|
||||
"file-manager",
|
||||
"fleets",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user