From cd3b01c4b439930fb52fda6f95ba8ff160676062 Mon Sep 17 00:00:00 2001 From: Dipak Date: Sun, 19 Jul 2026 10:22:38 +0530 Subject: [PATCH] feat(web): add a light theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-way theme choice in Settings (Dark / True black / Light), replacing the OLED checkbox: - theme.ts: ThemeName gains "light"; stored-value validation keeps unknown values falling back to dark; applyTheme sets data-theme for any non-default theme. - tokens.css: a [data-theme=light] block — paper surfaces, ink text, darkened hairlines; the coral accent shifts to its deep value for contrast on paper; warn/err darkened for AA. The code card stays a dark panel deliberately. - TerminalView: light mode swaps the FULL xterm palette (One Light-derived), not just the background — the dark ANSI ramp would be unreadable on paper. Live switch keeps working via rc-theme-change. - theme.test.ts extended for the new value, fallback, and meta mirror. Closes #67 --- packages/web/src/chat/TerminalView.tsx | 43 ++++++++++++-- packages/web/src/pwa/theme.test.ts | 8 ++- packages/web/src/pwa/theme.ts | 20 ++++--- packages/web/src/settings/SettingsPanel.tsx | 29 +++++----- packages/web/src/styles/tokens.css | 63 +++++++++++++++++++++ 5 files changed, 135 insertions(+), 28 deletions(-) diff --git a/packages/web/src/chat/TerminalView.tsx b/packages/web/src/chat/TerminalView.tsx index 025283b4..d68fa994 100644 --- a/packages/web/src/chat/TerminalView.tsx +++ b/packages/web/src/chat/TerminalView.tsx @@ -7,7 +7,7 @@ import { useState, type PointerEvent as ReactPointerEvent, } from "react"; -import { Terminal } from "@xterm/xterm"; +import { Terminal, type ITheme } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import "@xterm/xterm/css/xterm.css"; @@ -405,6 +405,39 @@ const THEME = { brightWhite: "#ffffff", } as const; +/** Light-theme overrides (One Light-derived): xterm keeps painting its own colors, so the dark ANSI ramp + * (pale yellow/white on paper) would be unreadable — swap the whole palette, not just the background. */ +const LIGHT_THEME = { + background: TERMINAL_BG.light, + foreground: "#383a42", + cursor: "#383a42", + cursorAccent: "#fafafa", + selectionBackground: "#d2d2d9", + selectionInactiveBackground: "#e1e1e6", + 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; + +/** The xterm theme for the SAVED app theme: light swaps the full palette; dark/oled only re-base the background. */ +function terminalTheme(): ITheme { + const t = loadTheme(); + return t === "light" ? { ...LIGHT_THEME } : { ...THEME, background: TERMINAL_BG[t] }; +} + /** Copy text to the OS clipboard, ROBUSTLY: the async Clipboard API first, then a hidden-textarea * execCommand('copy') fallback for when the async API is blocked/unavailable (older WebKit, a non-gesture * call, a permissions quirk). Returns whether it landed. */ @@ -896,8 +929,8 @@ export function TerminalView({ // Retain xterm's modifier override as a legacy fallback. Roamcode's desktop gesture arbitration below // makes ordinary drag select by default, so users never need to discover Option/Shift themselves. macOptionClickForcesSelection: true, - // xterm paints its own background, so it can't inherit var(--bg) — follow the saved theme (OLED = #000). - theme: { ...THEME, background: TERMINAL_BG[loadTheme()] }, + // xterm paints its own colors, so it can't inherit the CSS tokens — follow the saved theme. + theme: terminalTheme(), allowProposedApi: true, // OSC 8 can carry an arbitrary URI behind terminal text. Keep xterm's non-http(s) protection on and // route safe web links through the same opener as visible URLs. @@ -910,9 +943,9 @@ export function TerminalView({ scrollback: 1000, }); 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 = { ...THEME, background: TERMINAL_BG[loadTheme()] }; + term.options.theme = terminalTheme(); }; window.addEventListener("rc-theme-change", onThemeChange); const fit = new FitAddon(); diff --git a/packages/web/src/pwa/theme.test.ts b/packages/web/src/pwa/theme.test.ts index 8a0c53ca..842430c8 100644 --- a/packages/web/src/pwa/theme.test.ts +++ b/packages/web/src/pwa/theme.test.ts @@ -16,6 +16,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 +32,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 (xterm can't inherit CSS vars)", () => { +test("the terminal background map covers every theme (xterm 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", () => { diff --git a/packages/web/src/pwa/theme.ts b/packages/web/src/pwa/theme.ts index b0c2be04..15952ae7 100644 --- a/packages/web/src/pwa/theme.ts +++ b/packages/web/src/pwa/theme.ts @@ -1,11 +1,12 @@ /** - * 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), or "light" (paper surfaces for + * bright-daylight use). 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"; +export type ThemeName = "dark" | "oled" | "light"; const KEY = "roamcode.theme"; @@ -14,17 +15,20 @@ const KEY = "roamcode.theme"; 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 { try { - return localStorage.getItem(KEY) === "oled" ? "oled" : "dark"; + const stored = localStorage.getItem(KEY); + return stored === "oled" || stored === "light" ? stored : "dark"; } catch { return "dark"; } @@ -34,8 +38,8 @@ export function loadTheme(): ThemeName { * Safe anywhere (no-ops without a document). */ export function applyTheme(theme: ThemeName): void { if (typeof document === "undefined") return; - if (theme === "oled") document.documentElement.dataset.theme = "oled"; - else delete document.documentElement.dataset.theme; + 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]); } diff --git a/packages/web/src/settings/SettingsPanel.tsx b/packages/web/src/settings/SettingsPanel.tsx index 087812ce..baee743d 100644 --- a/packages/web/src/settings/SettingsPanel.tsx +++ b/packages/web/src/settings/SettingsPanel.tsx @@ -341,24 +341,25 @@ 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. */} -