- Themes: built-in presets (Ember/Forest/Ocean/Rosé) + custom-theme editor (per-token color pickers, live preview, JSON export/import). src/themes.ts. - Plugin platform: JS/ESM plugins loaded via blob-module import (CSP script-src blob:), Host SDK with menu / file-context / view / submit-hook contribution points + permission model. Backend commands to list/read/enable/ install/remove plugins under <config>/plugins/. Example plugin + PLUGINS.md. - Settings redesigned into a sectioned hub (General / Appearance / Plugins); Themes and Plugins moved in, removed from the Actions menu. - "NEW" badge on History commits submitted < 3h ago. - Smooth tab transitions: sliding underline + content fade on Changes/History, and matching fades on the view-panel and dock tab groups. - Fix: settings close button used the default browser style. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
134 lines
6.2 KiB
TypeScript
134 lines
6.2 KiB
TypeScript
// Theme system — built-in + user-made custom themes.
|
|
//
|
|
// A theme = a base (dark/light) plus an overlay of CSS-variable values applied as
|
|
// inline styles on <body>, which win over the :root / body.light stylesheet vars.
|
|
// Everything is CSS-variable driven, so a theme just changes token values.
|
|
|
|
export type ThemeBase = "dark" | "light";
|
|
export interface Theme {
|
|
id: string;
|
|
name: string;
|
|
base: ThemeBase;
|
|
builtin?: boolean;
|
|
vars: Record<string, string>; // e.g. { "--accent": "#7c6ef6" }
|
|
}
|
|
|
|
// The tokens a user can edit. Grouped for the editor UI. Every one is a color.
|
|
export const THEME_TOKENS: { var: string; label: string; group: string }[] = [
|
|
{ var: "--accent", label: "Accent", group: "Brand" },
|
|
{ var: "--accent-2", label: "Accent light", group: "Brand" },
|
|
{ var: "--accent-deep", label: "Accent deep", group: "Brand" },
|
|
{ var: "--bg", label: "Background", group: "Surfaces" },
|
|
{ var: "--panel", label: "Panel", group: "Surfaces" },
|
|
{ var: "--panel-2", label: "Panel 2", group: "Surfaces" },
|
|
{ var: "--panel-3", label: "Panel 3", group: "Surfaces" },
|
|
{ var: "--elevated", label: "Elevated", group: "Surfaces" },
|
|
{ var: "--border", label: "Border", group: "Surfaces" },
|
|
{ var: "--txt", label: "Text", group: "Text" },
|
|
{ var: "--muted", label: "Muted text", group: "Text" },
|
|
{ var: "--faint", label: "Faint text", group: "Text" },
|
|
{ var: "--add", label: "Add / success", group: "Status" },
|
|
{ var: "--edit", label: "Edit / warning", group: "Status" },
|
|
{ var: "--del", label: "Delete / danger", group: "Status" },
|
|
];
|
|
export const TOKEN_VARS = THEME_TOKENS.map((t) => t.var);
|
|
|
|
// Built-in presets. "dark"/"light" carry no overlay (pure stylesheet base); the
|
|
// rest recolor a few tokens on top of a base to give a distinct look.
|
|
export const BUILTIN_THEMES: Theme[] = [
|
|
{ id: "dark", name: "Midnight (default)", base: "dark", builtin: true, vars: {} },
|
|
{ id: "light", name: "Daylight", base: "light", builtin: true, vars: {} },
|
|
{ id: "ember", name: "Ember", base: "dark", builtin: true, vars: { "--accent": "#f0883e", "--accent-2": "#f7a75f", "--accent-deep": "#c96a22", "--bg": "#0f0b07", "--panel": "#1a130c", "--panel-2": "#20180f", "--panel-3": "#281d12" } },
|
|
{ id: "forest", name: "Forest", base: "dark", builtin: true, vars: { "--accent": "#35c88a", "--accent-2": "#5fe0a5", "--accent-deep": "#22a06e", "--bg": "#07100b", "--panel": "#0e1a13", "--panel-2": "#12211a", "--panel-3": "#182a20" } },
|
|
{ id: "ocean", name: "Ocean", base: "dark", builtin: true, vars: { "--accent": "#3aa0ff", "--accent-2": "#6cbaff", "--accent-deep": "#1f7ce0", "--bg": "#060b12", "--panel": "#0c141f", "--panel-2": "#101a28", "--panel-3": "#152234" } },
|
|
{ id: "rose", name: "Rosé (light)", base: "light", builtin: true, vars: { "--accent": "#e0426a", "--accent-2": "#f06489", "--accent-deep": "#c02b52", "--bg": "#faf3f5" } },
|
|
];
|
|
|
|
const CUSTOM_KEY = "exd-themes-custom";
|
|
const ACTIVE_KEY = "exd-theme-id";
|
|
const LEGACY_KEY = "exd-theme"; // old "light"/"dark" flag
|
|
|
|
export function loadCustomThemes(): Theme[] {
|
|
try {
|
|
const raw = localStorage.getItem(CUSTOM_KEY);
|
|
if (!raw) return [];
|
|
const arr = JSON.parse(raw);
|
|
return Array.isArray(arr) ? (arr as Theme[]).filter((t) => t && t.id && t.vars) : [];
|
|
} catch { return []; }
|
|
}
|
|
export function saveCustomThemes(list: Theme[]) {
|
|
try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(list)); } catch {}
|
|
}
|
|
export function allThemes(): Theme[] {
|
|
return [...BUILTIN_THEMES, ...loadCustomThemes()];
|
|
}
|
|
export function loadActiveId(): string {
|
|
try {
|
|
const id = localStorage.getItem(ACTIVE_KEY);
|
|
if (id) return id;
|
|
// migrate from the old dark/light flag
|
|
return localStorage.getItem(LEGACY_KEY) === "light" ? "light" : "dark";
|
|
} catch { return "dark"; }
|
|
}
|
|
export function saveActiveId(id: string) {
|
|
try {
|
|
localStorage.setItem(ACTIVE_KEY, id);
|
|
const th = allThemes().find((t) => t.id === id);
|
|
localStorage.setItem(LEGACY_KEY, th?.base === "light" ? "light" : "dark"); // keep legacy in sync
|
|
} catch {}
|
|
}
|
|
export function resolveTheme(id: string): Theme {
|
|
return allThemes().find((t) => t.id === id) || BUILTIN_THEMES[0]!;
|
|
}
|
|
|
|
// Apply a theme to the document: set the base class, then overlay its var values
|
|
// (clearing any previous overlay first).
|
|
export function applyTheme(theme: Theme) {
|
|
const body = document.body;
|
|
for (const v of TOKEN_VARS) body.style.removeProperty(v);
|
|
body.classList.toggle("light", theme.base === "light");
|
|
for (const [k, val] of Object.entries(theme.vars)) {
|
|
if (val) body.style.setProperty(k, val);
|
|
}
|
|
}
|
|
|
|
// Read the effective value of a token right now (for seeding the editor). Falls
|
|
// back to the theme's overlay or the computed stylesheet value.
|
|
export function tokenValue(theme: Theme, varName: string): string {
|
|
if (theme.vars[varName]) return theme.vars[varName]!;
|
|
const el = document.createElement("div");
|
|
el.style.color = `var(${varName})`;
|
|
// temporarily set base so computed value reflects the right stylesheet
|
|
const hadLight = document.body.classList.contains("light");
|
|
document.body.classList.toggle("light", theme.base === "light");
|
|
document.body.appendChild(el);
|
|
const rgb = getComputedStyle(el).color;
|
|
el.remove();
|
|
document.body.classList.toggle("light", hadLight);
|
|
return rgbToHex(rgb) || "#7c6ef6";
|
|
}
|
|
|
|
function rgbToHex(rgb: string): string | null {
|
|
const m = rgb.match(/rgba?\(([^)]+)\)/);
|
|
if (!m || !m[1]) return null;
|
|
const parts = m[1].split(",").map((x) => parseInt(x.trim(), 10));
|
|
const [r, g, b] = parts;
|
|
if (r == null || g == null || b == null) return null;
|
|
const h = (n: number) => n.toString(16).padStart(2, "0");
|
|
return `#${h(r)}${h(g)}${h(b)}`;
|
|
}
|
|
|
|
export function newCustomId(): string {
|
|
// no Date.now/Math.random dependency worries in the app runtime; keep it simple
|
|
return "custom-" + Math.random().toString(36).slice(2, 9);
|
|
}
|
|
|
|
export function exportTheme(theme: Theme): string {
|
|
return JSON.stringify({ name: theme.name, base: theme.base, vars: theme.vars }, null, 2);
|
|
}
|
|
export function importTheme(json: string): Theme {
|
|
const o = JSON.parse(json);
|
|
if (!o || typeof o !== "object" || !o.vars) throw new Error("Invalid theme file");
|
|
return { id: newCustomId(), name: String(o.name || "Imported theme"), base: o.base === "light" ? "light" : "dark", vars: o.vars };
|
|
}
|