mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +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
|
- 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
|
- 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
|
## 26.10.2
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,13 @@
|
|||||||
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
|
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
|
||||||
"defaultLook": true
|
"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",
|
"id": "docker",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Plugin API 1.4.0.
|
|||||||
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
|
- `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/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
|
- `@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
|
### 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`,
|
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
|
||||||
`--foreground`, `--card`, `--border`, `--muted` and the rest).
|
`--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
|
## Looking native
|
||||||
|
|
||||||
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
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 * as esbuild from "esbuild";
|
||||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||||
import { staticUrlImports } from "../lib/static-url-imports.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 { openapi } from "./openapi.mjs";
|
||||||
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
||||||
|
|
||||||
@@ -232,32 +237,41 @@ export async function build({ cwd }) {
|
|||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
||||||
if (!backendEntry) {
|
if (!backendEntry && !isThemeOnly(manifest)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await esbuild.build({
|
if (backendEntry) {
|
||||||
entryPoints: [backendEntry],
|
await esbuild.build({
|
||||||
outfile: path.join(outDir, "backend.js"),
|
entryPoints: [backendEntry],
|
||||||
bundle: true,
|
outfile: path.join(outDir, "backend.js"),
|
||||||
format: "esm",
|
bundle: true,
|
||||||
platform: "node",
|
format: "esm",
|
||||||
target: "node22",
|
platform: "node",
|
||||||
sourcemap: true,
|
target: "node22",
|
||||||
logLevel: "warning",
|
sourcemap: true,
|
||||||
// A native dependency's compiled .node binary is resolved by the
|
logLevel: "warning",
|
||||||
// package's own relative paths, which break once esbuild inlines its JS
|
// A native dependency's compiled .node binary is resolved by the
|
||||||
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
// package's own relative paths, which break once esbuild inlines its JS
|
||||||
// node_modules import instead, resolved at runtime like a host-provided
|
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
||||||
// package.
|
// node_modules import instead, resolved at runtime like a host-provided
|
||||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
// package.
|
||||||
loader: BINARY_LOADERS,
|
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||||
// Bundled CJS deps still call require() for Node builtins at runtime.
|
loader: BINARY_LOADERS,
|
||||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
// Bundled CJS deps still call require() for Node builtins at runtime.
|
||||||
banner: { js: REQUIRE_BANNER },
|
// 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)) {
|
for (const worker of readWorkerEntries(cwd, pluginId)) {
|
||||||
await esbuild.build({
|
await esbuild.build({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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
|
* 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.
|
// The manifest names files. They have to be there.
|
||||||
const referenced = [
|
const referenced = [
|
||||||
raw.backend ?? "dist/backend.js",
|
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",
|
raw.locales ?? "locales",
|
||||||
];
|
];
|
||||||
for (const rel of referenced) {
|
for (const rel of referenced) {
|
||||||
@@ -35,6 +36,7 @@ export async function validate({ cwd }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
|
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
|
||||||
|
problems.push(...validateLookCapabilities(cwd, raw));
|
||||||
problems.push(
|
problems.push(
|
||||||
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
||||||
);
|
);
|
||||||
@@ -78,6 +80,39 @@ export function validateThemeFiles(cwd, raw, validateThemeCss) {
|
|||||||
return problems;
|
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. */
|
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||||
const CORE_ENV = new Set([
|
const CORE_ENV = new Set([
|
||||||
"DATA_DIR",
|
"DATA_DIR",
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ export function readManifest(cwd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The first of these that exists, or null. */
|
/** 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) {
|
export function resolveEntry(cwd, candidates) {
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const full = path.join(cwd, candidate);
|
const full = path.join(cwd, candidate);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const OFFICIAL_IDS = [
|
|||||||
"alerts",
|
"alerts",
|
||||||
"automations",
|
"automations",
|
||||||
"classic-layout",
|
"classic-layout",
|
||||||
|
"classic-themes",
|
||||||
"docker",
|
"docker",
|
||||||
"file-manager",
|
"file-manager",
|
||||||
"fleets",
|
"fleets",
|
||||||
|
|||||||
@@ -35,6 +35,42 @@ function fixturePlugin(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("termix-plugin build", () => {
|
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 () => {
|
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
|
||||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
const dir = fixturePlugin();
|
const dir = fixturePlugin();
|
||||||
|
|||||||
@@ -99,3 +99,48 @@ describe("termix-plugin validate themes", () => {
|
|||||||
expect(output).not.toContain("theme paper");
|
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 */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import type { ThemeId } from "@/types/ui-types";
|
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";
|
import { applyThemeCss } from "@/look/theme-css";
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
type ThemeProviderProps = {
|
||||||
@@ -41,7 +46,9 @@ export function ThemeProvider({
|
|||||||
// Plugin themes arrive after login; the class and CSS follow them.
|
// Plugin themes arrive after login; the class and CSS follow them.
|
||||||
const themes = useThemes();
|
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(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement;
|
const root = window.document.documentElement;
|
||||||
@@ -60,7 +67,7 @@ export function ThemeProvider({
|
|||||||
}, [activeTheme, themes]);
|
}, [activeTheme, themes]);
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
theme,
|
theme: shownTheme,
|
||||||
setTheme: (theme: ThemeId, persist = true) => {
|
setTheme: (theme: ThemeId, persist = true) => {
|
||||||
if (persist) localStorage.setItem(storageKey, theme);
|
if (persist) localStorage.setItem(storageKey, theme);
|
||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
|
|||||||
@@ -136,288 +136,6 @@
|
|||||||
--terminal-bg: #0c0d0b;
|
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
/* Tailwind v4 defaults border-color to currentColor, so any bare "border"
|
/* Tailwind v4 defaults border-color to currentColor, so any bare "border"
|
||||||
|
|||||||
@@ -1115,14 +1115,7 @@
|
|||||||
"colorPickerTooltip": "Open color picker",
|
"colorPickerTooltip": "Open color picker",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
"themeLight": "Light",
|
"themeLight": "Light",
|
||||||
"themeDark": "Dark",
|
"themeDark": "Dark"
|
||||||
"themeDracula": "Dracula",
|
|
||||||
"themeCatppuccin": "Catppuccin",
|
|
||||||
"themeNord": "Nord",
|
|
||||||
"themeSolarized": "Solarized",
|
|
||||||
"themeTokyoNight": "Tokyo Night",
|
|
||||||
"themeOneDark": "One Dark",
|
|
||||||
"themeGruvbox": "Gruvbox"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ export interface ThemeDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Themes core ships, with their tokens in index.css. A plugin theme with the
|
* The two themes core keeps as a fallback, with their tokens in index.css.
|
||||||
* same id takes the built-in's place while it is installed.
|
* 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[] = [
|
export const BUILTIN_THEMES: readonly ThemeDef[] = [
|
||||||
{
|
{
|
||||||
@@ -40,48 +41,6 @@ export const BUILTIN_THEMES: readonly ThemeDef[] = [
|
|||||||
mode: "dark",
|
mode: "dark",
|
||||||
swatch: { background: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
|
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]));
|
const builtins = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
|
||||||
@@ -96,15 +55,15 @@ function emit() {
|
|||||||
for (const listener of listeners) listener();
|
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[] {
|
export function listThemes(): ThemeDef[] {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
const replaced = BUILTIN_THEMES.map(
|
const replaced = BUILTIN_THEMES.map(
|
||||||
(theme) => pluginThemes.get(theme.id) ?? theme,
|
(theme) => pluginThemes.get(theme.id) ?? theme,
|
||||||
);
|
);
|
||||||
const extra = [...pluginThemes.values()]
|
const extra = [...pluginThemes.values()].filter(
|
||||||
.filter((theme) => !builtins.has(theme.id))
|
(theme) => !builtins.has(theme.id),
|
||||||
.sort((a, b) => a.id.localeCompare(b.id));
|
);
|
||||||
snapshot = [...replaced, ...extra];
|
snapshot = [...replaced, ...extra];
|
||||||
}
|
}
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@@ -154,7 +113,16 @@ export function themeMode(id: string): "light" | "dark" {
|
|||||||
? "dark"
|
? "dark"
|
||||||
: "light";
|
: "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 {
|
export function themeLabel(theme: ThemeDef, t: TFunction): string {
|
||||||
|
|||||||
@@ -75,4 +75,36 @@ describe("ThemeProvider", () => {
|
|||||||
expect(classes()).toEqual(["dark"]);
|
expect(classes()).toEqual(["dark"]);
|
||||||
expect(document.getElementById("termix-theme-css")).toBeNull();
|
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");
|
const [, layoutDefault, themeDefault] = screen.getAllByRole("combobox");
|
||||||
fireEvent.change(layoutDefault, { target: { value: "fancy" } });
|
fireEvent.change(layoutDefault, { target: { value: "fancy" } });
|
||||||
expect(look.chooseDefaultLook).toHaveBeenCalledWith({ layout: "fancy" });
|
expect(look.chooseDefaultLook).toHaveBeenCalledWith({ layout: "fancy" });
|
||||||
fireEvent.change(themeDefault, { target: { value: "nord" } });
|
fireEvent.change(themeDefault, { target: { value: "light" } });
|
||||||
expect(look.chooseDefaultLook).toHaveBeenCalledWith({ theme: "nord" });
|
expect(look.chooseDefaultLook).toHaveBeenCalledWith({ theme: "light" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -155,9 +155,9 @@ describe("RepairView", () => {
|
|||||||
fireEvent.click(screen.getByText("Classic"));
|
fireEvent.click(screen.getByText("Classic"));
|
||||||
expect(look.chooseLayout).toHaveBeenCalledWith("classic");
|
expect(look.chooseLayout).toHaveBeenCalledWith("classic");
|
||||||
fireEvent.click(
|
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 () => {
|
it("resets to the default look", async () => {
|
||||||
|
|||||||
@@ -2,65 +2,81 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
BUILTIN_THEMES,
|
BUILTIN_THEMES,
|
||||||
arePluginThemesKnown,
|
arePluginThemesKnown,
|
||||||
|
effectiveThemeId,
|
||||||
getTheme,
|
getTheme,
|
||||||
listThemes,
|
listThemes,
|
||||||
resetPluginThemes,
|
resetPluginThemes,
|
||||||
setPluginThemes,
|
setPluginThemes,
|
||||||
themeLabel,
|
themeLabel,
|
||||||
themeMode,
|
themeMode,
|
||||||
|
type ThemeDef,
|
||||||
} from "@/look/theme-registry";
|
} from "@/look/theme-registry";
|
||||||
|
|
||||||
afterEach(() => resetPluginThemes());
|
afterEach(() => resetPluginThemes());
|
||||||
|
|
||||||
const swatch = { background: "#000000", panel: "#111111", text: "#ffffff" };
|
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", () => {
|
describe("theme registry", () => {
|
||||||
it("lists the built-ins in their order", () => {
|
it("keeps only a light and a dark theme in core", () => {
|
||||||
expect(listThemes().map((theme) => theme.id)).toEqual(
|
expect(BUILTIN_THEMES.map((theme) => theme.id)).toEqual(["light", "dark"]);
|
||||||
BUILTIN_THEMES.map((theme) => theme.id),
|
expect(listThemes().map((theme) => theme.id)).toEqual(["light", "dark"]);
|
||||||
);
|
|
||||||
expect(arePluginThemesKnown()).toBe(false);
|
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([
|
setPluginThemes([
|
||||||
{
|
{
|
||||||
id: "nord",
|
...pluginTheme("dark"),
|
||||||
label: "themes.nord",
|
cssHref: "/plugin-assets/more-themes/_themes/dark.css",
|
||||||
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("nord"),
|
||||||
|
pluginTheme("dracula"),
|
||||||
|
pluginTheme("paper", "light"),
|
||||||
]);
|
]);
|
||||||
const ids = listThemes().map((theme) => theme.id);
|
const ids = listThemes().map((theme) => theme.id);
|
||||||
expect(ids.indexOf("nord")).toBe(
|
expect(ids).toEqual(["light", "dark", "nord", "dracula", "paper"]);
|
||||||
BUILTIN_THEMES.findIndex((theme) => theme.id === "nord"),
|
expect(getTheme("dark")?.pluginId).toBe("more-themes");
|
||||||
);
|
|
||||||
expect(ids[ids.length - 1]).toBe("paper");
|
|
||||||
expect(getTheme("nord")?.pluginId).toBe("more-themes");
|
|
||||||
expect(arePluginThemesKnown()).toBe(true);
|
expect(arePluginThemesKnown()).toBe(true);
|
||||||
|
|
||||||
setPluginThemes([]);
|
setPluginThemes([]);
|
||||||
expect(getTheme("nord")?.pluginId).toBeUndefined();
|
expect(getTheme("dark")?.pluginId).toBeUndefined();
|
||||||
expect(getTheme("paper")).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", () => {
|
it("reports each theme's mode, following the OS for system", () => {
|
||||||
expect(themeMode("light")).toBe("light");
|
expect(themeMode("light")).toBe("light");
|
||||||
expect(themeMode("dracula")).toBe("dark");
|
|
||||||
expect(["light", "dark"]).toContain(themeMode("system"));
|
expect(["light", "dark"]).toContain(themeMode("system"));
|
||||||
setPluginThemes([
|
setPluginThemes([pluginTheme("paper", "light"), pluginTheme("nord")]);
|
||||||
{ id: "paper", label: "Paper", mode: "light", swatch, pluginId: "x" },
|
|
||||||
]);
|
|
||||||
expect(themeMode("paper")).toBe("light");
|
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", () => {
|
it("translates core labels in core and plugin labels in the plugin", () => {
|
||||||
|
|||||||
@@ -113,8 +113,9 @@ describe("useTheme", () => {
|
|||||||
);
|
);
|
||||||
act(() => setTheme!("paper"));
|
act(() => setTheme!("paper"));
|
||||||
expect(result.current.theme).toBe("light");
|
expect(result.current.theme).toBe("light");
|
||||||
|
// Gone themes follow the OS once the plugin list is known.
|
||||||
act(() => setTheme!("dracula"));
|
act(() => setTheme!("dracula"));
|
||||||
expect(result.current.theme).toBe("dark");
|
expect(["light", "dark"]).toContain(result.current.theme);
|
||||||
resetPluginThemes();
|
resetPluginThemes();
|
||||||
localStorage.removeItem("bridge-theme");
|
localStorage.removeItem("bridge-theme");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,17 +95,17 @@ describe("ThemeGrid", () => {
|
|||||||
render(<ThemeGrid value="dark" onChange={onChange} />);
|
render(<ThemeGrid value="dark" onChange={onChange} />);
|
||||||
const buttons = screen.getAllByRole("button");
|
const buttons = screen.getAllByRole("button");
|
||||||
expect(buttons).toHaveLength(THEMES.length);
|
expect(buttons).toHaveLength(THEMES.length);
|
||||||
fireEvent.click(buttons[THEMES.findIndex((t) => t.id === "gruvbox")]);
|
fireEvent.click(buttons[THEMES.findIndex((t) => t.id === "light")]);
|
||||||
expect(onChange).toHaveBeenCalledWith("gruvbox");
|
expect(onChange).toHaveBeenCalledWith("light");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("previews a theme while it is hovered", () => {
|
it("previews a theme while it is hovered", () => {
|
||||||
render(<ThemeGrid value="dark" onChange={vi.fn()} />);
|
render(<ThemeGrid value="dark" onChange={vi.fn()} />);
|
||||||
const nord =
|
const light =
|
||||||
screen.getAllByRole("button")[THEMES.findIndex((t) => t.id === "nord")];
|
screen.getAllByRole("button")[THEMES.findIndex((t) => t.id === "light")];
|
||||||
fireEvent.mouseEnter(nord);
|
fireEvent.mouseEnter(light);
|
||||||
expect(theme.setThemePreview).toHaveBeenLastCalledWith("nord");
|
expect(theme.setThemePreview).toHaveBeenLastCalledWith("light");
|
||||||
fireEvent.mouseLeave(nord);
|
fireEvent.mouseLeave(light);
|
||||||
expect(theme.setThemePreview).toHaveBeenLastCalledWith(null);
|
expect(theme.setThemePreview).toHaveBeenLastCalledWith(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user