diff --git a/packages/web/src/chat/TerminalView.tsx b/packages/web/src/chat/TerminalView.tsx index b22174a..154d412 100644 --- a/packages/web/src/chat/TerminalView.tsx +++ b/packages/web/src/chat/TerminalView.tsx @@ -43,7 +43,7 @@ import { ChatHeader } from "./ChatHeader"; import { Icon } from "../ui/Icon"; import { InlineConfirm } from "../ui/InlineConfirm"; import { healPaintBurst } from "../pwa/viewport"; -import { loadTheme, TERMINAL_BG } from "../pwa/theme"; +import { loadTheme, resolveTheme, TERMINAL_BG } from "../pwa/theme"; import { useFocusTrap } from "../ui/useFocusTrap"; import type { SessionMeta } from "../types/server"; import { providerDisplayName } from "../session/provider-display"; @@ -373,30 +373,59 @@ const THEME = { brightWhite: "#ffffff", } as const; +/** One Light-derived palette. Ghostty paints its own ANSI colors, so on the paper background the dark ramp + * (pale yellow/white text) would be unreadable — swap the whole palette for light, not just the background. */ +const LIGHT_THEME = { + foreground: "#383a42", + cursor: "#383a42", + selectionBackground: "#d2d2d9", + selectionForeground: "#383a42", + black: "#383a42", + red: "#ca1243", + green: "#3f8b3f", + yellow: "#a06500", + blue: "#2f5fd0", + magenta: "#a626a4", + cyan: "#0b7a99", + white: "#fafafa", + brightBlack: "#696c77", + brightRed: "#ca1243", + brightGreen: "#3f8b3f", + brightYellow: "#a06500", + brightBlue: "#2f5fd0", + brightMagenta: "#a626a4", + brightCyan: "#0b7a99", + brightWhite: "#ffffff", +} as const; + function ghosttyTheme(): GhosttyTerminalTheme { + // "system" resolves to light/dark here, and the OS-flip watcher re-fires rc-theme-change so this re-runs + // live. light swaps the whole palette; dark/oled share the dark ramp and only re-base the background. + const theme = resolveTheme(loadTheme()); + const src = theme === "light" ? LIGHT_THEME : THEME; return { - background: TERMINAL_BG[loadTheme()], - foreground: THEME.foreground, - cursor: THEME.cursor, - selectionBackground: THEME.selectionBackground, - selectionForeground: THEME.selectionForeground, + background: TERMINAL_BG[theme], + foreground: src.foreground, + cursor: src.cursor, + selectionBackground: src.selectionBackground, + selectionForeground: src.selectionForeground, palette: [ - THEME.black, - THEME.red, - THEME.green, - THEME.yellow, - THEME.blue, - THEME.magenta, - THEME.cyan, - THEME.white, - THEME.brightBlack, - THEME.brightRed, - THEME.brightGreen, - THEME.brightYellow, - THEME.brightBlue, - THEME.brightMagenta, - THEME.brightCyan, - THEME.brightWhite, + src.black, + src.red, + src.green, + src.yellow, + src.blue, + src.magenta, + src.cyan, + src.white, + src.brightBlack, + src.brightRed, + src.brightGreen, + src.brightYellow, + src.brightBlue, + src.brightMagenta, + src.brightCyan, + src.brightWhite, ], }; } @@ -901,7 +930,7 @@ export function GhosttyProductTerminalView({ }, }); termRef.current = term; - // Live theme switch (Settings → OLED toggle) restyles the OPEN terminal without a remount. + // Live theme switch (Settings → theme picker) restyles the OPEN terminal without a remount. const onThemeChange = (): void => { term.options.theme = ghosttyTheme(); }; diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 806d33f..372cd8c 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -9,7 +9,7 @@ import { installAppGestureGuards } from "./pwa/app-gestures"; import { installViewportSync } from "./pwa/viewport"; import { isIosWebKit } from "./pwa/platform"; import { respondToServiceWorkerVersionProbe } from "./pwa/sw-version-handshake"; -import { applyTheme, loadTheme } from "./pwa/theme"; +import { applyTheme, loadTheme, watchSystemTheme } from "./pwa/theme"; import { installWakeLock } from "./pwa/wake-lock"; import { migrateLegacyStorage } from "./storage-migration"; import { BUILD_VERSION } from "./build-info"; @@ -18,8 +18,11 @@ import { BUILD_VERSION } from "./build-info"; // `roamcode.*` so existing devices keep their token/theme/settings across the rename. if (typeof localStorage !== "undefined") migrateLegacyStorage(localStorage); -// Apply the saved theme (dark / OLED true-black) BEFORE the first paint so there's no near-black→black flash. +// Apply the saved theme (dark / OLED true-black / light / system) BEFORE the first paint so there's no +// theme flash; then follow OS scheme flips live while the preference is "system". Lives for the app's +// lifetime — no disposer needed. applyTheme(loadTheme()); +watchSystemTheme(); // Mirror the visual viewport into --app-height so the shell shrinks to the area above the on-screen keyboard // (instead of the composer / terminal cursor hiding behind it). Started before render so the first paint is diff --git a/packages/web/src/pwa/theme.test.ts b/packages/web/src/pwa/theme.test.ts index 5ae085b..4d9daae 100644 --- a/packages/web/src/pwa/theme.test.ts +++ b/packages/web/src/pwa/theme.test.ts @@ -1,11 +1,24 @@ -import { afterEach, expect, test } from "vitest"; -import { applyTheme, loadTheme, setTheme, TERMINAL_BG } from "./theme"; +import { afterEach, expect, test, vi } from "vitest"; +import { applyTheme, loadTheme, resolveTheme, setTheme, TERMINAL_BG, watchSystemTheme } from "./theme"; afterEach(() => { localStorage.clear(); delete document.documentElement.dataset.theme; + vi.unstubAllGlobals(); }); +/** A minimal MediaQueryList stub for the prefers-color-scheme query (jsdom has no matchMedia). */ +function stubSystemScheme(light: boolean): { fireChange: () => void } { + const listeners = new Set<() => void>(); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("prefers-color-scheme: light") ? light : false, + media: query, + addEventListener: (_: string, fn: () => void) => listeners.add(fn), + removeEventListener: (_: string, fn: () => void) => listeners.delete(fn), + })); + return { fireChange: () => listeners.forEach((fn) => fn()) }; +} + test("defaults to dark when nothing is stored (or storage holds junk)", () => { expect(loadTheme()).toBe("dark"); localStorage.setItem("roamcode.theme", "neon"); @@ -16,6 +29,9 @@ test("setTheme persists + applies data-theme; switching back removes it", () => setTheme("oled"); expect(loadTheme()).toBe("oled"); expect(document.documentElement.dataset.theme).toBe("oled"); + setTheme("light"); + expect(loadTheme()).toBe("light"); + expect(document.documentElement.dataset.theme).toBe("light"); setTheme("dark"); expect(loadTheme()).toBe("dark"); // dark is the :root default — the attribute must be REMOVED (not set to "dark") so the override block never matches. @@ -29,14 +45,17 @@ test("applyTheme mirrors the theme-color meta when present", () => { document.head.appendChild(meta); applyTheme("oled"); expect(meta.getAttribute("content")).toBe("#000000"); + applyTheme("light"); + expect(meta.getAttribute("content")).toBe("#f7f6f3"); applyTheme("dark"); expect(meta.getAttribute("content")).toBe("#0a0a0b"); meta.remove(); }); -test("the terminal background map covers both themes (the canvas can't inherit CSS vars)", () => { +test("the terminal background map covers every theme (the canvas can't inherit CSS vars)", () => { expect(TERMINAL_BG.oled).toBe("#000000"); expect(TERMINAL_BG.dark).toBe("#0a0a0b"); + expect(TERMINAL_BG.light).toBe("#f7f6f3"); }); test("setTheme announces rc-theme-change so an open terminal can restyle live", () => { @@ -49,3 +68,42 @@ test("setTheme announces rc-theme-change so an open terminal can restyle live", window.removeEventListener("rc-theme-change", on); expect(seen).toBe("oled"); }); + +test("system persists, resolves per prefers-color-scheme, and falls back to dark without matchMedia", () => { + // jsdom has no matchMedia — "system" must resolve to dark, not crash. + setTheme("system"); + expect(loadTheme()).toBe("system"); + expect(resolveTheme("system")).toBe("dark"); + expect(document.documentElement.dataset.theme).toBeUndefined(); + + stubSystemScheme(true); + expect(resolveTheme("system")).toBe("light"); + applyTheme("system"); + expect(document.documentElement.dataset.theme).toBe("light"); + + // Concrete preferences never consult the OS. + expect(resolveTheme("oled")).toBe("oled"); +}); + +test("watchSystemTheme re-applies + announces on an OS flip only while the preference is system", () => { + const scheme = stubSystemScheme(true); + let announced = 0; + const on = (): void => { + announced += 1; + }; + window.addEventListener("rc-theme-change", on); + const dispose = watchSystemTheme(); + + setTheme("system"); // announces once itself + expect(document.documentElement.dataset.theme).toBe("light"); + scheme.fireChange(); + expect(announced).toBe(2); + + setTheme("oled"); // concrete preference → OS flips are ignored + scheme.fireChange(); + expect(announced).toBe(3); + expect(document.documentElement.dataset.theme).toBe("oled"); + + dispose(); + window.removeEventListener("rc-theme-change", on); +}); diff --git a/packages/web/src/pwa/theme.ts b/packages/web/src/pwa/theme.ts index 628217f..54643c5 100644 --- a/packages/web/src/pwa/theme.ts +++ b/packages/web/src/pwa/theme.ts @@ -1,56 +1,98 @@ /** - * Theme preference: the default near-black "dark" vs "oled" (TRUE #000 black — on an OLED panel those pixels - * are simply off, so the app burns less battery and blacks read bottomless). A CLIENT-side preference (same - * localStorage convention as session names): applied by setting `data-theme` on , which tokens.css uses - * to override the surface palette. Applied at boot (main.tsx, before first paint) and instantly from Settings. + * Theme preference: the default near-black "dark", "oled" (TRUE #000 black — on an OLED panel those pixels + * are simply off, so the app burns less battery and blacks read bottomless), "light" (a paper-white + * palette for bright-daylight use), or "system" (follow the OS prefers-color-scheme, light ↔ dark). A + * CLIENT-side preference (same localStorage convention as session names): applied by setting `data-theme` + * on , which tokens.css uses to override the surface palette. Applied at boot (main.tsx, before + * first paint) and instantly from Settings. */ -export type ThemeName = "dark" | "oled"; +/** A concrete palette (what tokens.css / xterm can actually render). */ +export type ThemeName = "dark" | "oled" | "light"; + +/** What the user PICKED in Settings — a concrete theme, or "system" (resolved per prefers-color-scheme). */ +export type ThemePreference = ThemeName | "system"; const KEY = "roamcode.theme"; +const LIGHT_QUERY = "(prefers-color-scheme: light)"; /** The terminal background for each theme — Ghostty paints its own canvas background, so it can't * inherit the CSS token; TerminalView reads this at mount + on the rc-theme-change event. */ export const TERMINAL_BG: Record = { dark: "#0a0a0b", oled: "#000000", + light: "#f7f6f3", }; /** The browser-chrome color (status bar / title bar) per theme — mirrored into . */ const THEME_COLOR: Record = { dark: "#0a0a0b", oled: "#000000", + light: "#f7f6f3", }; -export function loadTheme(): ThemeName { +export function loadTheme(): ThemePreference { try { - return localStorage.getItem(KEY) === "oled" ? "oled" : "dark"; + const stored = localStorage.getItem(KEY); + return stored === "oled" || stored === "light" || stored === "system" ? stored : "dark"; } catch { return "dark"; } } +/** The concrete palette for a preference: "system" follows the OS scheme (light ↔ dark). Falls back to + * dark where matchMedia is unavailable (jsdom / SSR) — same guard idiom as AppLayout's useIsDesktop. */ +export function resolveTheme(preference: ThemePreference): ThemeName { + if (preference !== "system") return preference; + return typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(LIGHT_QUERY).matches + ? "light" + : "dark"; +} + /** Apply the theme to the document: the data-theme attribute (tokens.css keys off it) + the theme-color meta. * Safe anywhere (no-ops without a document). */ -export function applyTheme(theme: ThemeName): void { +export function applyTheme(preference: ThemePreference): void { if (typeof document === "undefined") return; - if (theme === "oled") document.documentElement.dataset.theme = "oled"; - else delete document.documentElement.dataset.theme; + const theme = resolveTheme(preference); + // dark is the :root default — the attribute is REMOVED (not set to "dark") so no override block matches. + if (theme === "dark") delete document.documentElement.dataset.theme; + else document.documentElement.dataset.theme = theme; const meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.setAttribute("content", THEME_COLOR[theme]); } /** Persist + apply + announce (rc-theme-change) so live views (the open terminal) can restyle immediately. */ -export function setTheme(theme: ThemeName): void { +export function setTheme(preference: ThemePreference): void { try { - localStorage.setItem(KEY, theme); + localStorage.setItem(KEY, preference); } catch { /* private mode — the preference just won't persist */ } - applyTheme(theme); + applyTheme(preference); try { - window.dispatchEvent(new CustomEvent("rc-theme-change", { detail: theme })); + window.dispatchEvent(new CustomEvent("rc-theme-change", { detail: preference })); } catch { /* CustomEvent unavailable — live views will pick the theme up on next mount */ } } + +/** While the preference is "system", re-apply + announce when the OS scheme flips, so the app (and an open + * terminal, via rc-theme-change) follows a daylight/night switch live. Call once at boot; returns a + * disposer. No-op where matchMedia is unavailable. */ +export function watchSystemTheme(): () => void { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {}; + const mql = window.matchMedia(LIGHT_QUERY); + const onChange = (): void => { + if (loadTheme() !== "system") return; + applyTheme("system"); + try { + window.dispatchEvent(new CustomEvent("rc-theme-change", { detail: "system" })); + } catch { + /* live views will pick the theme up on next mount */ + } + }; + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); +} diff --git a/packages/web/src/settings/SettingsPanel.tsx b/packages/web/src/settings/SettingsPanel.tsx index 76f48b2..f9bb07e 100644 --- a/packages/web/src/settings/SettingsPanel.tsx +++ b/packages/web/src/settings/SettingsPanel.tsx @@ -11,7 +11,7 @@ import { ExtensionsPanel } from "./ExtensionsPanel"; import { TeamAccess } from "./TeamAccess"; import { OrganizationControls } from "./OrganizationControls"; import { shortenReset, usageFillColor } from "../session/UsageBars"; -import { loadTheme, setTheme, type ThemeName } from "../pwa/theme"; +import { loadTheme, setTheme, type ThemePreference } from "../pwa/theme"; import type { SessionOrder } from "../session/order-preference"; import { providerDisplayName } from "../session/provider-display"; @@ -87,8 +87,8 @@ export function SettingsPanel({ }: SettingsPanelProps) { const shellFirstSession = session?.launch?.kind === "shell" || (session?.launch === undefined && !session?.provider); const observedProvider = session?.agent?.provider ?? (shellFirstSession ? undefined : session?.provider); - // Appearance: the OLED true-black toggle. Mirrors the persisted theme; setTheme applies it instantly. - const [theme, setThemeState] = useState(() => loadTheme()); + // Appearance: the dark / OLED / light / system theme picker. Mirrors the persisted theme; setTheme applies it instantly. + const [theme, setThemeState] = useState(() => loadTheme()); // Usage: prefer the prop; otherwise self-fetch via `api` (so the near-limit warning works without the // app wiring a new prop). `undefined` prop means "not provided → fetch"; `null` means "hide". const [fetchedUsage, setFetchedUsage] = useState(undefined); @@ -361,24 +361,26 @@ export function SettingsPanel({ Theme and session list preferences - {/* OLED true-black: applies INSTANTLY (no save button) — a client-side preference persisted in - this browser's localStorage, like session names. On an OLED panel #000 pixels are off. */} -