diff --git a/src/App.tsx b/src/App.tsx index a6b22d7..766058d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -77,6 +77,7 @@ import { changeLabel, toggleBroadcast as toggleBroadcastInTree, toggleMcpAllow as toggleMcpAllowInTree, + setLeafColors as setLeafColorsInTree, setAllBroadcast, adjustFontSize, adjustAllFontSizes, @@ -106,6 +107,13 @@ import Palette from "./components/Palette"; import HostManager from "./components/HostManager"; import Help from "./components/Help"; import McpPanel from "./components/McpPanel"; +import ColorPanel from "./components/ColorPanel"; +import { + type PaneColors, + GLOBAL_COLORS_STORAGE_KEY, + loadGlobalColors, + saveGlobalColors, +} from "./lib/theme"; import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm"; import TabStrip from "./components/TabStrip"; import "./App.css"; @@ -239,6 +247,15 @@ export default function App() { token: null, }); const [mcpPanelOpen, setMcpPanelOpen] = useState(false); + // App-wide default terminal colours, loaded from localStorage. Per-pane + // overrides live on the LeafNode (colorOverride); this is the fallback. + const [globalColors, setGlobalColors] = useState(() => + loadGlobalColors(), + ); + const [colorPanelOpen, setColorPanelOpen] = useState(false); + const [colorPanelMode, setColorPanelMode] = useState<"global" | "pane">( + "global", + ); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -647,6 +664,34 @@ export default function App() { setTree((t) => toggleMcpAllowInTree(t, leafId)); }, []); + const setLeafColors = useCallback( + (leafId: NodeId, colors: PaneColors | undefined) => { + setTree((t) => setLeafColorsInTree(t, leafId, colors)); + }, + [], + ); + + const openColorPanel = useCallback((leafId?: NodeId) => { + if (leafId) setActiveLeafId(leafId); + setColorPanelMode(leafId ? "pane" : "global"); + setColorPanelOpen(true); + }, [setActiveLeafId]); + + // Persist the global theme on every change, and pick up edits made in + // OTHER windows. localStorage is shared per-origin: the `storage` event + // fires only in windows that did NOT make the write, so this can't loop. + useEffect(() => { + saveGlobalColors(globalColors); + }, [globalColors]); + useEffect(() => { + function onStorage(e: StorageEvent) { + if (e.key !== GLOBAL_COLORS_STORAGE_KEY) return; + setGlobalColors(loadGlobalColors()); + } + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + // ---- MCP server lifecycle ------------------------------------------------ const refreshMcpStatus = useCallback(async () => { try { @@ -1265,13 +1310,16 @@ export default function App() { activeLeafId, distros, hosts, + globalColors, split, close, setShell, setLabel, toggleBroadcast, toggleMcpAllow, + setLeafColors, openHostManager, + openColorPanel, setActive, navigateTo, registerPaneId, @@ -1290,13 +1338,16 @@ export default function App() { activeLeafId, distros, hosts, + globalColors, split, close, setShell, setLabel, toggleBroadcast, toggleMcpAllow, + setLeafColors, openHostManager, + openColorPanel, setActive, navigateTo, registerPaneId, @@ -2086,6 +2137,14 @@ export default function App() { > 🤖 + + + + {/* Target toggle: edit the global default or just the active pane. */} +
+ + +
+ +
+

+ {paneMode + ? "These colours override the global theme for the active pane only. Unset rows inherit the global default." + : "These colours apply to every pane that doesn't have its own override. Saved across restarts and shared with new windows."} +

+ + {/* Editable colour rows */} +
+ {FIELDS.map(({ key, label }) => { + const value = resolved[key]!; + const inherited = paneMode && !isSet(key); + return ( +
+ {label} + setField(key, e.target.value)} + aria-label={label} + /> + { + const v = e.target.value.trim(); + if (HEX_RE.test(v)) setField(key, v); + }} + /> + {paneMode && + (inherited ? ( + + inherited + + ) : ( + + ))} +
+ ); + })} +
+ + {/* Live preview */} + + + {/* Presets */} +
+ Presets +
+ {COLOR_PRESETS.map((p) => ( + + ))} +
+
+ +
+ +
+
+ + + ); +} diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index a993c11..402b729 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -25,6 +25,11 @@ import { type SpawnSpec, } from "../ipc"; import type { NavigateIntent } from "../lib/layout/orchestration"; +import { + type PaneColors, + DEFAULT_PANE_COLORS, + toXtermTheme, +} from "../lib/theme"; // --------------------------------------------------------------------------- // base64 helpers (private to this module) @@ -76,6 +81,9 @@ interface XtermPaneProps { focusTrigger?: number; /** Absolute font size in px. Changes are applied live (fit + PTY resize). */ fontSize?: number; + /** Fully-resolved terminal colours (global theme merged with any per-pane + * override). Changes are applied live to the running terminal. */ + colors?: Required; /** Called when the user presses a tiling-WM navigation chord inside the * terminal. XtermPane only emits the intent; the parent (LeafPane/App) * resolves the target leaf from the current layout and sets it active. @@ -100,6 +108,7 @@ export default function XtermPane({ onFocus, focusTrigger = 0, fontSize, + colors, onNavigate, }: XtermPaneProps) { const containerRef = useRef(null); @@ -112,6 +121,9 @@ export default function XtermPane({ // up the initial value without re-running when it changes (the secondary // effect below handles dynamic updates). const initialFontSizeRef = useRef(fontSize); + // Same trick for the initial theme — the mount effect reads this once; the + // secondary effect below applies later changes live. + const initialColorsRef = useRef(colors); // Stable refs for callbacks so the mount effect doesn't need to re-run when // parents pass new inline functions, while still always calling the latest version. @@ -144,10 +156,12 @@ export default function XtermPane({ fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace', fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE, cursorBlink: true, - theme: { - background: "#0c0c0c", - foreground: "#e6e6e6", - }, + // Theme is resolved by the parent (global default merged with any + // per-pane override) and applied live by the effect below. The fixed + // slice — softened white/brightWhite that tame the Claude TUI's + // emphasis slots so nothing hits glaring pure white — lives in + // toXtermTheme / BASE_XTERM_THEME (see lib/theme.ts). + theme: toXtermTheme(initialColorsRef.current ?? DEFAULT_PANE_COLORS), scrollback: 5000, convertEol: false, allowProposedApi: true, @@ -537,6 +551,27 @@ export default function XtermPane({ } }, [fontSize]); + // ------------------------------------------------------------------------- + // Live colour-theme changes (global theme edit, per-pane override, preset). + // + // Setting term.options.theme re-tints the renderer immediately; a refresh + // forces the canvas surface to repaint already-drawn cells with the new + // palette (xterm only re-tints on the next write otherwise). Cell geometry + // is unaffected, so no fit()/resize is needed — unlike the font-size path. + // ------------------------------------------------------------------------- + useEffect(() => { + const term = termRef.current; + if (!term || !colors) return; + try { + term.options.theme = toXtermTheme(colors); + term.refresh(0, term.rows - 1); + } catch (e) { + console.warn("theme apply failed", e); + } + // Depend on the individual fields rather than the object identity so a + // parent that rebuilds an equal colours object each render doesn't churn. + }, [colors?.background, colors?.foreground, colors?.cursor, colors?.selection]); + // Close the search bar and return focus to the xterm textarea so the user // can resume typing immediately. Queries the well-known xterm helper // textarea selector — the same pattern used in the focusTrigger effect. diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index e087694..35b1c7e 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; +import { resolvePaneColors } from "../theme"; import { useOrchestration } from "./orchestration"; import XtermPane from "../../components/XtermPane"; import type { SpawnSpec } from "../../ipc"; @@ -547,6 +548,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { 🤖 + + {isIdle && statusOk ? ( idle @@ -604,6 +621,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { onNavigate={onPaneNavigate} focusTrigger={focusTrigger} fontSize={resolveFontSize(leaf.fontSizeOffset)} + colors={resolvePaneColors(orch.globalColors, leaf.colorOverride)} /> ) : (
diff --git a/src/lib/layout/orchestration.tsx b/src/lib/layout/orchestration.tsx index 46effad..10d90d5 100644 --- a/src/lib/layout/orchestration.tsx +++ b/src/lib/layout/orchestration.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, type ReactNode } from "react"; import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree"; import type { PaneId, SshHost } from "../../ipc"; +import type { PaneColors } from "../theme"; /** * Orchestration context — every piece of shared state and every operation @@ -21,6 +22,10 @@ export interface Orchestration { /** Saved SSH hosts loaded from `hosts.json`. Reactive — changes when the * user edits hosts via {@link openHostManager}. */ hosts: SshHost[]; + /** App-wide default terminal colours. Reactive — edited via the colour + * panel. Each leaf resolves its effective theme from this plus its own + * {@link LeafNode.colorOverride}. */ + globalColors: PaneColors; // Tree mutations split: (leafId: NodeId, orientation: Orientation) => void; @@ -34,9 +39,15 @@ export interface Orchestration { /** Flip the per-pane mcpAllow flag. Default-deny; chip in the pane * toolbar drives this. */ toggleMcpAllow: (leafId: NodeId) => void; + /** Set or clear a leaf's per-pane colour override (undefined → fall back + * to the global theme). */ + setLeafColors: (leafId: NodeId, colors: PaneColors | undefined) => void; // SSH host management openHostManager: () => void; + /** Open the colour panel. When `leafId` is given the panel starts in + * per-pane mode targeting that leaf; otherwise it edits the global theme. */ + openColorPanel: (leafId?: NodeId) => void; // Per-pane orchestration setActive: (leafId: NodeId) => void; diff --git a/src/lib/layout/tree.test.ts b/src/lib/layout/tree.test.ts index 41f9d08..2d14c60 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -13,6 +13,7 @@ import { changeLabel, toggleBroadcast, toggleMcpAllow, + setLeafColors, adjustFontSize, adjustAllFontSizes, resolveFontSize, @@ -302,12 +303,13 @@ describe("setLeafShell", () => { expect(next.id).not.toBe(leaf.id); }); - it("preserves label / broadcast / fontSizeOffset across the shell change", () => { + it("preserves label / broadcast / fontSizeOffset / colorOverride across the shell change", () => { const leaf = newLeaf({ distro: "Ubuntu", label: "my pane", broadcast: true, fontSizeOffset: 2, + colorOverride: { background: "#101010" }, }); const next = setLeafShell(leaf, leaf.id, { shellKind: "powershell", @@ -315,6 +317,7 @@ describe("setLeafShell", () => { expect(next.label).toBe("my pane"); expect(next.broadcast).toBe(true); expect(next.fontSizeOffset).toBe(2); + expect(next.colorOverride).toEqual({ background: "#101010" }); }); }); @@ -389,6 +392,58 @@ describe("toggleMcpAllow", () => { }); }); +describe("setLeafColors", () => { + it("sets an override on a leaf with none", () => { + const leaf = newLeaf(); + expect(leaf.colorOverride).toBeUndefined(); + const next = setLeafColors(leaf, leaf.id, { + background: "#001122", + foreground: "#ddeeff", + }) as LeafNode; + expect(next.colorOverride).toEqual({ + background: "#001122", + foreground: "#ddeeff", + }); + }); + + it("replaces an existing override wholesale", () => { + const leaf = newLeaf({ colorOverride: { background: "#000000" } }); + const next = setLeafColors(leaf, leaf.id, { cursor: "#ff0000" }) as LeafNode; + expect(next.colorOverride).toEqual({ cursor: "#ff0000" }); + }); + + it("clears the override when passed undefined", () => { + const leaf = newLeaf({ colorOverride: { background: "#000000" } }); + const next = setLeafColors(leaf, leaf.id, undefined) as LeafNode; + expect(next.colorOverride).toBeUndefined(); + expect("colorOverride" in next).toBe(false); + }); + + it("clears the override when passed an all-undefined object", () => { + const leaf = newLeaf({ colorOverride: { background: "#000000" } }); + const next = setLeafColors(leaf, leaf.id, { + background: undefined, + foreground: undefined, + cursor: undefined, + selection: undefined, + }) as LeafNode; + expect(next.colorOverride).toBeUndefined(); + expect("colorOverride" in next).toBe(false); + }); + + it("returns the same reference when clearing an already-unset override", () => { + const leaf = newLeaf(); + const next = setLeafColors(leaf, leaf.id, undefined); + expect(next).toBe(leaf); + }); + + it("MUST NOT swap the leaf id (metadata-only, no PTY respawn)", () => { + const leaf = newLeaf(); + const next = setLeafColors(leaf, leaf.id, { background: "#123456" }) as LeafNode; + expect(next.id).toBe(leaf.id); + }); +}); + describe("resolveFontSize", () => { it("returns the default when offset is undefined or 0", () => { expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index 352dbec..e02904b 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -5,6 +5,8 @@ //! tmux / i3 / Zellij use — dragging a gutter mutates one parent ratio, //! both sibling subtrees reflow automatically. +import type { PaneColors } from "../theme"; + export type NodeId = string; /** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */ @@ -44,6 +46,13 @@ export interface LeafNode { * later doesn't require migrating saved workspaces. */ fontSizeOffset?: number; + /** + * Per-pane colour override. Any field set here wins over the app-wide + * global theme (see {@link resolvePaneColors}); unset fields fall through. + * Undefined / empty means "use the global theme". Metadata-only — changing + * it never respawns the PTY. + */ + colorOverride?: PaneColors; /** * If true, this pane is visible to the MCP server (Claude can list it, * read its scrollback, etc.). Default-DENY: when undefined or false, the @@ -111,6 +120,7 @@ export function setLeafShell( label: node.label, broadcast: node.broadcast, fontSizeOffset: node.fontSizeOffset, + colorOverride: node.colorOverride, }; if (spec.shellKind === "wsl") { if (spec.distro !== undefined) base.distro = spec.distro; @@ -294,6 +304,32 @@ export function toggleMcpAllow(root: TreeNode, leafId: NodeId): TreeNode { }); } +/** Set (or clear) a leaf's per-pane colour override. Pass `undefined` or an + * empty object to drop the override so the pane falls back to the global + * theme. Metadata-only — does NOT swap the id, so the PTY keeps running. */ +export function setLeafColors( + root: TreeNode, + leafId: NodeId, + colors: PaneColors | undefined, +): TreeNode { + return replaceById(root, leafId, (node) => { + if (node.kind !== "leaf") return node; + const empty = + !colors || + (colors.background === undefined && + colors.foreground === undefined && + colors.cursor === undefined && + colors.selection === undefined); + if (empty) { + if (node.colorOverride === undefined) return node; + const next: LeafNode = { ...node }; + delete next.colorOverride; + return next; + } + return { ...node, colorOverride: colors }; + }); +} + /** Compute the actual pixel font size from a leaf's offset, clamped to * [MIN_FONT_SIZE, MAX_FONT_SIZE]. */ export function resolveFontSize(offset: number | undefined): number { @@ -383,6 +419,7 @@ export function reshapeToPreset( if (src.label !== undefined) slot.label = src.label; if (src.broadcast !== undefined) slot.broadcast = src.broadcast; if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset; + if (src.colorOverride !== undefined) slot.colorOverride = src.colorOverride; if (src.mcpAllow !== undefined) slot.mcpAllow = src.mcpAllow; } diff --git a/src/lib/theme.test.ts b/src/lib/theme.test.ts new file mode 100644 index 0000000..e17c1d5 --- /dev/null +++ b/src/lib/theme.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { + resolvePaneColors, + toXtermTheme, + DEFAULT_PANE_COLORS, + COLOR_PRESETS, + type PaneColors, +} from "./theme"; + +describe("resolvePaneColors", () => { + it("falls back to defaults when nothing is set", () => { + expect(resolvePaneColors(undefined, undefined)).toEqual(DEFAULT_PANE_COLORS); + }); + + it("uses global values over defaults", () => { + const global: PaneColors = { background: "#111111", cursor: "#abcdef" }; + const r = resolvePaneColors(global, undefined); + expect(r.background).toBe("#111111"); + expect(r.cursor).toBe("#abcdef"); + // Unset fields still come from defaults. + expect(r.foreground).toBe(DEFAULT_PANE_COLORS.foreground); + expect(r.selection).toBe(DEFAULT_PANE_COLORS.selection); + }); + + it("per-pane override wins over global, field by field", () => { + const global: PaneColors = { background: "#111111", foreground: "#222222" }; + const override: PaneColors = { background: "#999999" }; + const r = resolvePaneColors(global, override); + expect(r.background).toBe("#999999"); // override wins + expect(r.foreground).toBe("#222222"); // inherits global + expect(r.cursor).toBe(DEFAULT_PANE_COLORS.cursor); // inherits default + }); + + it("always returns all four fields defined", () => { + const r = resolvePaneColors({}, {}); + expect(Object.keys(r).sort()).toEqual([ + "background", + "cursor", + "foreground", + "selection", + ]); + }); +}); + +describe("toXtermTheme", () => { + it("maps resolved colours onto the xterm ITheme shape", () => { + const theme = toXtermTheme({ + background: "#0c0c0c", + foreground: "#c5c8c6", + cursor: "#ffffff", + selection: "#3a3a3a", + }); + expect(theme.background).toBe("#0c0c0c"); + expect(theme.foreground).toBe("#c5c8c6"); + expect(theme.cursor).toBe("#ffffff"); + // selection maps to xterm 5.x's renamed property. + expect(theme.selectionBackground).toBe("#3a3a3a"); + // cursorAccent is pinned to the background for block-cursor legibility. + expect(theme.cursorAccent).toBe("#0c0c0c"); + }); + + it("keeps the fixed softened white/brightWhite slice", () => { + const theme = toXtermTheme(DEFAULT_PANE_COLORS); + expect(theme.white).toBe("#c5c8c6"); + expect(theme.brightWhite).toBe("#e0e0e0"); + }); +}); + +describe("COLOR_PRESETS", () => { + it("starts with the tiletopia default and every preset is fully specified", () => { + expect(COLOR_PRESETS[0].name).toBe("Tiletopia Dark"); + expect(COLOR_PRESETS[0].colors).toEqual(DEFAULT_PANE_COLORS); + for (const p of COLOR_PRESETS) { + for (const key of ["background", "foreground", "cursor", "selection"] as const) { + expect(p.colors[key]).toMatch(/^#[0-9a-fA-F]{6}$/); + } + } + }); +}); diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..ca70c3d --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,160 @@ +//! 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); + } +}