tiletopia/src/lib/theme.ts
megaproxy 7e624a3f96 Add customizable terminal colors (global theme + per-pane overrides)
Four editable colors (background/foreground/cursor/selection) via a new
ColorPanel modal with built-in presets and live preview. Global default
persists to localStorage and syncs across windows; per-pane overrides ride
on LeafNode.colorOverride in the workspace tree. Titlebar 🎨 button edits
the global theme; per-pane 🎨 chip overrides a single pane. Subsumes the
prior uncommitted softened-foreground tweak into lib/theme.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:41:19 +01:00

160 lines
5.9 KiB
TypeScript

//! Terminal colour theming.
//!
//! tiletopia ships one hard-coded dark palette historically baked into
//! XtermPane. This module turns that into a customisable model:
//!
//! - a GLOBAL default theme (persisted to localStorage, app-wide), and
//! - optional PER-PANE overrides (stored on the LeafNode, persisted with the
//! workspace tree).
//!
//! Only four colours are user-editable — background, foreground, cursor, and
//! selection — the ones that actually move the needle on readability. The
//! rest of xterm's ITheme (the 16-colour ANSI palette, etc.) stays fixed in
//! {@link BASE_XTERM_THEME}: notably `white`/`brightWhite` keep the softened
//! values that tame the Claude TUI's emphasis slots (see XtermPane history).
import type { ITheme } from "@xterm/xterm";
/** The four user-editable colours. All optional: an undefined field on a
* per-pane override falls through to the global default; an undefined field
* on the global default falls through to {@link DEFAULT_PANE_COLORS}. */
export interface PaneColors {
/** Terminal background. */
background?: string;
/** Default text colour. */
foreground?: string;
/** Cursor block colour. */
cursor?: string;
/** Selection highlight background. */
selection?: string;
}
/** Fixed slice of the xterm theme that is NOT user-editable. The softened
* white/brightWhite values date back to the original hard-coded theme — they
* keep the Claude TUI's emphasis text from hitting glaring pure white. */
const BASE_XTERM_THEME: ITheme = {
white: "#c5c8c6",
brightWhite: "#e0e0e0",
};
/** Ground-truth defaults — the historical tiletopia palette. Every editable
* field resolves to one of these when nothing overrides it. Also exposed as
* the first preset ("Tiletopia Dark"). */
export const DEFAULT_PANE_COLORS: Required<PaneColors> = {
background: "#0c0c0c",
foreground: "#c5c8c6",
cursor: "#ffffff",
selection: "#3a3a3a",
};
/** A named, ready-to-apply colour set shown as a one-click starting point in
* the colour panel. */
export interface ColorPreset {
name: string;
colors: Required<PaneColors>;
}
/** Built-in presets. The first is the tiletopia default; the rest are
* well-known community palettes (background/foreground/cursor/selection
* only — the ANSI ramp is left to {@link BASE_XTERM_THEME}). */
export const COLOR_PRESETS: ColorPreset[] = [
{ name: "Tiletopia Dark", colors: DEFAULT_PANE_COLORS },
{
name: "Solarized Dark",
colors: { background: "#002b36", foreground: "#839496", cursor: "#93a1a1", selection: "#073642" },
},
{
name: "Gruvbox Dark",
colors: { background: "#282828", foreground: "#ebdbb2", cursor: "#ebdbb2", selection: "#504945" },
},
{
name: "Dracula",
colors: { background: "#282a36", foreground: "#f8f8f2", cursor: "#f8f8f2", selection: "#44475a" },
},
{
name: "Nord",
colors: { background: "#2e3440", foreground: "#d8dee9", cursor: "#d8dee9", selection: "#434c5e" },
},
{
name: "Light",
colors: { background: "#fafafa", foreground: "#1c1c1c", cursor: "#1c1c1c", selection: "#cfe0ff" },
},
];
/** Merge a per-pane override on top of the global default, then fill any
* still-missing field from {@link DEFAULT_PANE_COLORS}. The result always
* has all four fields defined. */
export function resolvePaneColors(
global: PaneColors | undefined,
override: PaneColors | undefined,
): Required<PaneColors> {
return {
background:
override?.background ?? global?.background ?? DEFAULT_PANE_COLORS.background,
foreground:
override?.foreground ?? global?.foreground ?? DEFAULT_PANE_COLORS.foreground,
cursor: override?.cursor ?? global?.cursor ?? DEFAULT_PANE_COLORS.cursor,
selection:
override?.selection ?? global?.selection ?? DEFAULT_PANE_COLORS.selection,
};
}
/** Build a full xterm ITheme from resolved colours. cursorAccent is pinned to
* the background so a block cursor's glyph stays readable. */
export function toXtermTheme(colors: Required<PaneColors>): ITheme {
return {
...BASE_XTERM_THEME,
background: colors.background,
foreground: colors.foreground,
cursor: colors.cursor,
cursorAccent: colors.background,
selectionBackground: colors.selection,
};
}
// ---------------------------------------------------------------------------
// Global-default persistence (localStorage; frontend-only, no backend hop).
// localStorage is shared across all windows of the same origin, so a new
// window picks up the saved theme at startup, and the `storage` event lets
// open windows react live (see App's listener).
// ---------------------------------------------------------------------------
export const GLOBAL_COLORS_STORAGE_KEY = "tiletopia.globalColors.v1";
/** #rgb / #rrggbb hex validator — what `<input type="color">` emits and what
* xterm accepts. We reject anything else so a corrupt localStorage value
* can't poison the theme. */
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function sanitizeColors(raw: unknown): PaneColors {
if (typeof raw !== "object" || raw === null) return {};
const o = raw as Record<string, unknown>;
const out: PaneColors = {};
for (const key of ["background", "foreground", "cursor", "selection"] as const) {
const v = o[key];
if (typeof v === "string" && HEX_RE.test(v)) out[key] = v;
}
return out;
}
/** Read the saved global theme. Returns {} (→ all defaults) when absent or
* unparseable. */
export function loadGlobalColors(): PaneColors {
try {
const raw = localStorage.getItem(GLOBAL_COLORS_STORAGE_KEY);
if (!raw) return {};
return sanitizeColors(JSON.parse(raw));
} catch {
return {};
}
}
/** Persist the global theme. Empty object is stored as-is (means "all
* defaults"), keeping the round-trip lossless. */
export function saveGlobalColors(colors: PaneColors): void {
try {
localStorage.setItem(GLOBAL_COLORS_STORAGE_KEY, JSON.stringify(colors));
} catch (e) {
console.warn("saveGlobalColors failed:", e);
}
}