Files
exbyte-depot-viewer-perforce/src/themes.ts
Bonchellon f0fc2f687a v0.3.5 — theme system, plugin platform, sectioned Settings, tab animations
- 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>
2026-07-09 13:50:45 +03:00

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 };
}