//! 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 = { 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; } /** 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 { 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): 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 `` 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; 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); } }