Add System/Light/Dark theme toggle to Settings (#161)

The app previously followed the OS via `prefers-color-scheme` only, with
no way to override or persist a theme. Add an Appearance section to
Settings with a System/Light/Dark segmented control.

- theme.ts: singleton store (useSyncExternalStore) that persists the
  preference to localStorage `orx:theme`, stamps `data-theme` on <html>,
  and keeps System mode live-syncing to the OS app-wide.
- index.html: pre-paint inline script resolves the theme before first
  render to avoid a flash of the wrong palette.
- styles.css: convert the two `@media (prefers-color-scheme: dark)`
  blocks to `:root[data-theme="dark"]` so JS drives the palette; add the
  segmented-control styles.
- SettingsPage.tsx: AppearanceTab with an accessible radiogroup
  (roving tabindex + arrow-key navigation, focus follows selection).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Myles Anderson
2026-08-06 16:56:31 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 899c3b68ac
commit 9508aed337
8 changed files with 453 additions and 214 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20 -3
View File
@@ -5,12 +5,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>OpenResearch CLI</title>
<script>
// Resolve the theme before first paint to avoid a flash of the wrong palette.
// Storage key and resolution must match src/theme.ts.
(function () {
try {
var pref = localStorage.getItem("orx:theme") || "system";
var dark =
pref === "dark" ||
(pref !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
} catch (e) {
document.documentElement.dataset.theme = "light";
}
})();
</script>
<style>
/* Pre-CSS background; keep in sync with --base in src/styles.css. */
html { background: #ffffff; }
@media (prefers-color-scheme: dark) { html { background: #0e0c0c; } }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
<script type="module" crossorigin src="/assets/index-hAp_dHD2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CjEh71JV.css">
<script type="module" crossorigin src="/assets/index-B3ti6x0R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CUB7SGBd.css">
</head>
<body>
<div id="root"></div>
+18 -1
View File
@@ -5,9 +5,26 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>OpenResearch CLI</title>
<script>
// Resolve the theme before first paint to avoid a flash of the wrong palette.
// Storage key and resolution must match src/theme.ts.
(function () {
try {
var pref = localStorage.getItem("orx:theme") || "system";
var dark =
pref === "dark" ||
(pref !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
} catch (e) {
document.documentElement.dataset.theme = "light";
}
})();
</script>
<style>
/* Pre-CSS background; keep in sync with --base in src/styles.css. */
html { background: #ffffff; }
@media (prefers-color-scheme: dark) { html { background: #0e0c0c; } }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
</head>
<body>
+81
View File
@@ -4,10 +4,13 @@ import {
Cpu,
ExternalLink,
Info,
Monitor,
Moon,
Plus,
RefreshCw,
Settings,
SquareTerminal,
Sun,
Trash2,
X,
} from "lucide-react";
@@ -78,6 +81,7 @@ import {
harnessModelLabel,
} from "../api";
import { onDataDirMove, onHarnessAuth } from "../events";
import { useThemePreference, type ThemePreference } from "../theme";
import { GitTokenForm } from "./GitTokenForm";
import { BackendBadge, BackendLogo } from "./BackendLogos";
import { ProgressBar } from "./ProgressBar";
@@ -1803,6 +1807,80 @@ function EnvVarsSection() {
);
}
// --- appearance ----------------------------------------------------------------
const THEME_OPTIONS: {
value: ThemePreference;
label: string;
icon: typeof Monitor;
}[] = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
];
function AppearanceTab() {
const [preference, setPreference] = useThemePreference();
// Arrow keys move selection relative to the focused radio, with focus
// following the new choice (WAI-ARIA radio pattern).
const onKeyDown = (e: React.KeyboardEvent) => {
const dir =
e.key === "ArrowRight" || e.key === "ArrowDown"
? 1
: e.key === "ArrowLeft" || e.key === "ArrowUp"
? -1
: 0;
if (!dir) return;
e.preventDefault();
const radios = [
...e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
];
const from = radios.findIndex((r) => r === document.activeElement);
const anchor =
from === -1 ? THEME_OPTIONS.findIndex((o) => o.value === preference) : from;
const next = (anchor + dir + THEME_OPTIONS.length) % THEME_OPTIONS.length;
setPreference(THEME_OPTIONS[next].value);
radios[next]?.focus();
};
return (
<>
<h2>Appearance</h2>
<p className="settings-sub">How the interface looks on this device.</p>
<div className="settings-card">
<div className="project-default-row">
<div>
<div className="project-default-title">Theme</div>
<p>System follows your operating system's light or dark setting.</p>
</div>
<div
className="theme-segmented"
role="radiogroup"
aria-label="Theme"
onKeyDown={onKeyDown}
>
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
role="radio"
aria-checked={preference === value}
tabIndex={preference === value ? 0 : -1}
className={`theme-segment ${preference === value ? "on" : ""}`}
onClick={() => setPreference(value)}
>
<Icon size={14} />
{label}
</button>
))}
</div>
</div>
</div>
</>
);
}
// --- project defaults ----------------------------------------------------------
function ProjectDefaultsTab() {
@@ -2528,6 +2606,9 @@ export function SettingsView({
<>
<h1>Settings</h1>
<div className="settings-stack">
<section className="settings-stack-section">
<AppearanceTab />
</section>
<section className="settings-stack-section">
<ProjectDefaultsTab />
</section>
+68 -31
View File
@@ -1,6 +1,7 @@
/* OpenResearch design system, mirrored from openresearch.sh www/src/styles.css.
Light theme by default, dark via prefers-color-scheme. Quiet idiom:
hairline borders, soft radii, flat surfaces that tint on hover. */
Light theme by default, dark via [data-theme="dark"] on <html> (resolved
before first paint by the inline script in index.html; see theme.ts). Quiet
idiom: hairline borders, soft radii, flat surfaces that tint on hover. */
:root {
--base: #ffffff;
@@ -94,38 +95,38 @@
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--base: #0e0c0c;
--canvas: #141110;
--panel: #221f1e;
--surface: #1d1b1a;
--surface-bright: #130f0f;
--highlight: #393433;
--text: #e6e1e0;
--subtext: #a68e8b;
--muted: #737373;
--primary: #ffb3ad;
--primary-subtle: #33191b;
--secondary: #3e7fcf;
--error: #ffb951;
--border: #525252;
--border-variant: #404040;
:root[data-theme="dark"] {
/* Theme is JS-resolved via <html data-theme>; do not add
@media (prefers-color-scheme) rules — override tokens here instead. */
--base: #0e0c0c;
--canvas: #141110;
--panel: #221f1e;
--surface: #1d1b1a;
--surface-bright: #130f0f;
--highlight: #393433;
--text: #e6e1e0;
--subtext: #a68e8b;
--muted: #737373;
--primary: #ffb3ad;
--primary-subtle: #33191b;
--secondary: #3e7fcf;
--error: #ffb951;
--border: #525252;
--border-variant: #404040;
--accent-amber: #e67e22;
--accent-amber: #e67e22;
--accent-green-subtle: #1c2b18;
--accent-amber-subtle: #33260f;
--accent-teal-subtle: #12332d;
--accent-red-subtle: #331418;
--accent-blue-subtle: #10233a;
--accent-purple-subtle: #251933;
--accent-green-subtle: #1c2b18;
--accent-amber-subtle: #33260f;
--accent-teal-subtle: #12332d;
--accent-red-subtle: #331418;
--accent-blue-subtle: #10233a;
--accent-purple-subtle: #251933;
--dots-muted: #2a2523;
--dots-strong: #555555;
--dots-muted: #2a2523;
--dots-strong: #555555;
color-scheme: dark;
}
color-scheme: dark;
}
* {
@@ -4260,7 +4261,7 @@ a.files-pill:hover {
:is(.md, .openresearch-diff, .file-view) .token.variable { color: #e45649; }
:is(.md, .openresearch-diff, .file-view) .token.parameter { color: #383a42; }
@media (prefers-color-scheme: dark) {
:root[data-theme="dark"] {
:is(.md, .openresearch-diff, .file-view) .token.comment,
:is(.md, .openresearch-diff, .file-view) .token.prolog,
:is(.md, .openresearch-diff, .file-view) .token.cdata { font-style: italic; color: #7f848e; }
@@ -4390,6 +4391,10 @@ a.files-pill:hover {
padding-top: 14px;
border-top: 1px solid var(--border-variant);
}
.settings-card > .project-default-row:first-child {
padding-top: 0;
border-top: none;
}
.project-default-title {
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
@@ -4450,6 +4455,38 @@ a.files-pill:hover {
outline: 2px solid var(--text);
outline-offset: 2px;
}
.theme-segmented {
display: inline-flex;
flex: 0 0 auto;
gap: 2px;
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.theme-segment {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: var(--radius-sm);
color: var(--subtext);
font-size: var(--fs-sm);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.theme-segment:hover:not(.on) {
color: var(--text);
background: var(--highlight);
}
.theme-segment.on {
color: var(--base);
background: var(--primary);
}
.theme-segment:focus-visible {
outline: 2px solid var(--text);
outline-offset: 2px;
}
.project-default-connect {
margin-top: 14px;
padding-top: 14px;
+72
View File
@@ -0,0 +1,72 @@
import { useSyncExternalStore } from "react";
export type ThemePreference = "system" | "light" | "dark";
const STORAGE_KEY = "orx:theme";
function readStoredPreference(): ThemePreference {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
} catch {
// localStorage may be unavailable (private mode, etc.); fall back to system.
}
return "system";
}
let preference: ThemePreference = readStoredPreference();
const listeners = new Set<() => void>();
function resolveTheme(pref: ThemePreference): "light" | "dark" {
if (pref !== "system") return pref;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyResolvedTheme(): void {
document.documentElement.dataset.theme = resolveTheme(preference);
}
export function setThemePreference(next: ThemePreference): void {
preference = next;
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// Non-fatal: the in-memory preference still applies for this session.
}
applyResolvedTheme();
for (const listener of listeners) listener();
}
// Keep "system" mode in sync when the OS theme flips while the app is open,
// regardless of which view is mounted.
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
if (preference === "system") applyResolvedTheme();
});
// The inline script in index.html sets the initial data-theme before paint;
// re-assert it here in case that script was blocked.
applyResolvedTheme();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Current theme preference, reflected on <html data-theme> and persisted. */
export function useThemePreference(): [
ThemePreference,
(next: ThemePreference) => void,
] {
const value = useSyncExternalStore(
subscribe,
() => preference,
() => preference,
);
return [value, setThemePreference];
}