Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions packages/web/src/chat/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand All @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion packages/web/src/pwa/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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", () => {
Expand Down
20 changes: 12 additions & 8 deletions packages/web/src/pwa/theme.ts
Original file line number Diff line number Diff line change
@@ -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 <html>, 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 <html>, 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";

Expand All @@ -14,17 +15,20 @@ const KEY = "roamcode.theme";
export const TERMINAL_BG: Record<ThemeName, string> = {
dark: "#0a0a0b",
oled: "#000000",
light: "#f7f6f3",
};

/** The browser-chrome color (status bar / title bar) per theme — mirrored into <meta name="theme-color">. */
const THEME_COLOR: Record<ThemeName, string> = {
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";
}
Expand All @@ -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]);
}
Expand Down
29 changes: 15 additions & 14 deletions packages/web/src/settings/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,24 +341,25 @@ export function SettingsPanel({
<span className="rc-settings__section-description">Theme and session list preferences</span>
</span>
</div>
{/* 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. */}
<label className="rc-settings__danger-check" style={{ color: "var(--text)" }}>
<input
type="checkbox"
aria-label="OLED black theme"
checked={theme === "oled"}
{/* Theme: applies INSTANTLY (no save button) — a client-side preference persisted in this
browser's localStorage, like session names. OLED = pure #000 (pixels off on OLED panels);
Light = paper surfaces for bright daylight. */}
<label className="rc-settings__field">
<span className="rc-settings__field-label">Theme</span>
<select
className="rc-settings__control"
aria-label="Theme"
value={theme}
onChange={(e) => {
const next = e.target.checked ? "oled" : "dark";
const next = e.target.value as ThemeName;
setThemeState(next);
setTheme(next);
}}
style={{ accentColor: "var(--coral)" }}
/>
<span className="rc-settings__option-copy">
<strong>True black theme</strong>
<small>Uses pure black for OLED displays.</small>
</span>
>
<option value="dark">Dark</option>
<option value="oled">True black (OLED)</option>
<option value="light">Light</option>
</select>
</label>
<label className="rc-settings__field">
<span className="rc-settings__field-label">Session order</span>
Expand Down
63 changes: 63 additions & 0 deletions packages/web/src/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,66 @@
--glass: rgba(6, 6, 7, 0.72);
--glass-strong: rgba(6, 6, 7, 0.85);
}

/* ---------------------------------------------------------------------------
LIGHT theme (data-theme="light" on <html>, set by pwa/theme.ts from Settings).
Paper surfaces for bright-daylight use: ink → paper, text flipped, hairlines
darkened. The coral accent shifts to its DEEP value so it stays readable on
paper; the "dark ink on coral" rule (--on-accent / --on-iris) is unchanged.
Tokens that reference other tokens (--cyan, --working, --ok, --user-bubble-*)
follow automatically. The code card deliberately STAYS a dark panel — code
reads as its own material on either theme.
--------------------------------------------------------------------------- */
:root[data-theme="light"] {
color-scheme: light;

/* Surfaces — paper with hairline borders (same flat hierarchy, inverted) */
--bg: #f7f6f3;
--surface: #ffffff;
--surface-2: #efeeea;
--surface-3: #e7e6e1;
--border: rgba(22, 22, 26, 0.1);
--border-strong: rgba(22, 22, 26, 0.18);

/* Text — near-black ink on paper */
--text: #1d1d22;
--text-muted: #595863;
--text-faint: #6b6a74;

/* Coral accent — the deep value clears contrast on paper */
--accent: #d6592c;
--accent-2: #c14e24;
--accent-soft: rgba(214, 89, 44, 0.12);
--accent-line: rgba(214, 89, 44, 0.45);
--accent-grad: var(--accent);
--iris: var(--accent-2);
--awaiting: var(--accent-2);
--awaiting-soft: rgba(214, 89, 44, 0.12);
--awaiting-line: rgba(214, 89, 44, 0.45);
--iris-card-border: rgba(214, 89, 44, 0.4);
--iris-line: rgba(214, 89, 44, 0.45);
--iris-soft: rgba(214, 89, 44, 0.12);

/* Semantic states — darkened for AA on paper */
--warn: #8a6100;
--err: #b3372b;
--err-soft: rgba(179, 55, 43, 0.1);
--err-line: rgba(179, 55, 43, 0.4);
--err-bg: rgba(179, 55, 43, 0.08);
--err-border: rgba(179, 55, 43, 0.4);
--ok-soft: rgba(0, 0, 0, 0.05);

/* Elevation + focus — softer neutral drops; scrim lightened */
--shadow: 0 16px 40px -24px rgba(22, 22, 26, 0.35);
--shadow-1: 0 8px 24px -18px rgba(22, 22, 26, 0.3);
--shadow-card: 0 18px 44px -28px rgba(22, 22, 26, 0.4);
--scrim: rgba(22, 22, 26, 0.35);
--focus-glow: 0 0 0 3px rgba(214, 89, 44, 0.25);

/* Atmosphere + glass — flat paper, no glow; glass goes translucent white */
--atmosphere: var(--bg);
--top-glow: none;
--glass: rgba(255, 255, 255, 0.78);
--glass-strong: rgba(255, 255, 255, 0.9);
--glass-shadow: 0 18px 44px -28px rgba(22, 22, 26, 0.4);
}