mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-10-02 01:34:34 +08:00
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:
co-authored by
Claude Opus 4.8
parent
899c3b68ac
commit
9508aed337
+162
-147
File diff suppressed because one or more lines are too long
Vendored
+32
File diff suppressed because one or more lines are too long
Vendored
-32
File diff suppressed because one or more lines are too long
Vendored
+20
-3
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user