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
75 changes: 52 additions & 23 deletions packages/web/src/chat/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
],
};
}
Expand Down Expand Up @@ -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();
};
Expand Down
7 changes: 5 additions & 2 deletions packages/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down
64 changes: 61 additions & 3 deletions packages/web/src/pwa/theme.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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.
Expand All @@ -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", () => {
Expand All @@ -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);
});
70 changes: 56 additions & 14 deletions packages/web/src/pwa/theme.ts
Original file line number Diff line number Diff line change
@@ -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 <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), "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 <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";
/** 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<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 {
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);
}
38 changes: 20 additions & 18 deletions packages/web/src/settings/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<ThemeName>(() => loadTheme());
// Appearance: the dark / OLED / light / system theme picker. Mirrors the persisted theme; setTheme applies it instantly.
const [theme, setThemeState] = useState<ThemePreference>(() => 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<UsageInfo | null | undefined>(undefined);
Expand Down Expand Up @@ -361,24 +361,26 @@ 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"}
onChange={(e) => {
const next = e.target.checked ? "oled" : "dark";
{/* Theme: applies INSTANTLY (no save button) — a client-side preference persisted in this
browser's localStorage, like session names. True black turns OLED pixels off; light is an
ink-on-paper palette for bright daylight; system follows the OS scheme (light ↔ dark). */}
<label className="rc-settings__field">
<span className="rc-settings__field-label">Theme</span>
<select
className="rc-settings__control"
aria-label="Theme"
value={theme}
onChange={(event) => {
const next = event.target.value as ThemePreference;
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>
<option value="system">System</option>
</select>
</label>
<label className="rc-settings__field">
<span className="rc-settings__field-label">Session order</span>
Expand Down
Loading
Loading