From 7020616d317cd6acc68101745923458ef58dd104 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Tue, 18 Aug 2026 12:53:04 +0800 Subject: [PATCH] feat(console): manual appearance toggle (System / Light / Dark) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio already rendered light + dark via `prefers-color-scheme` (auto-follows the OS) but gave the operator no way to override it. Add a toolbar toggle. - styles.css: the dark palette now applies on `:root[data-theme="dark"]` (forced) as well as `@media (prefers-color-scheme: dark) :root:not([data-theme])` (OS default when no explicit choice). `data-theme="light"` keeps the `:root` defaults, so a forced-light choice wins over a dark OS. - theme.ts: `data-theme` is the source of truth; the choice persists in localStorage (`oab-studio.theme`). Pure `cycleTheme` (System → Light → Dark) is unit-tested; `initThemeToggle` wires the button (label + persist + apply). - index.html: a no-FOUC head script applies the saved choice before first paint, plus the toolbar button next to "Check for updates". - main.ts: init the toggle at startup. Verification: `tsc --noEmit` clean · 89 vitest (theme +2) · `vite build` OK. --- console/index.html | 13 ++++++++ console/src/main.ts | 5 +++ console/src/styles.css | 44 +++++++++++++++++++++++++- console/src/theme.test.ts | 16 ++++++++++ console/src/theme.ts | 65 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 console/src/theme.test.ts create mode 100644 console/src/theme.ts diff --git a/console/index.html b/console/index.html index 3d6942b..9edeacc 100644 --- a/console/index.html +++ b/console/index.html @@ -5,6 +5,18 @@ OAB Studio — Console + +
@@ -14,6 +26,7 @@
+
diff --git a/console/src/main.ts b/console/src/main.ts index 814f81e..2958162 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -18,6 +18,7 @@ import type { import { createChatPanel, type ChatPanel } from "./chatPanel"; import { initAgentConsole, type AgentConsole } from "./agentConsole"; import { createPane, bindBackend, type Level } from "./log"; +import { initThemeToggle } from "./theme"; import { EditorView, basicSetup } from "codemirror"; import { EditorState } from "@codemirror/state"; import { StreamLanguage } from "@codemirror/language"; @@ -697,6 +698,10 @@ async function boot(): Promise { }); if (clusterLabel) clusterLabel.textContent = activeCluster; note("info", `app: polling cluster "${activeCluster}" every ${POLL_MS / 1000}s`); + // Appearance toggle (System / Light / Dark) — an override on top of the OS + // prefers-color-scheme default. + const themeBtn = document.getElementById("theme-btn"); + if (themeBtn) initThemeToggle(themeBtn as HTMLButtonElement); setupUpdater(); await startCore(); // Config tab: pin the oab-mcp target (cluster/profile/region → hermetic env); diff --git a/console/src/styles.css b/console/src/styles.css index 5362215..8042e10 100644 --- a/console/src/styles.css +++ b/console/src/styles.css @@ -15,8 +15,31 @@ --s-stopped: #6b7280; } +/* The dark palette. Applied when the operator forces dark + (`:root[data-theme="dark"]`) OR when the OS is dark and no explicit choice is + set (`:root:not([data-theme])`, below). `data-theme="light"` keeps the `:root` + defaults, so a forced-light choice wins over the OS. The `theme.ts` toggle and + the no-FOUC head script in index.html set `data-theme`. Keep the two dark + blocks in sync. */ +:root[data-theme="dark"] { + --bg: #0e1116; + --panel: #161b22; + --text: #e6edf3; + --muted: #8b949e; + --border: #21262d; + --ok: #3fb950; + --warn: #d29922; + + --s-starting: #58a6ff; + --s-running: #3fb950; + --s-paused: #d29922; + --s-unhealthy: #f0883e; + --s-stopping: #db8f2a; + --s-stopped: #8b949e; +} + @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme]) { --bg: #0e1116; --panel: #161b22; --text: #e6edf3; @@ -111,6 +134,25 @@ body { border-color: var(--s-starting); } +/* Appearance toggle — cycles System / Light / Dark. Mirrors the update button; + fixed min-width so the label swap doesn't shift the toolbar. */ +.theme-btn { + appearance: none; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + font: inherit; + font-size: 12px; + padding: 3px 10px; + border-radius: 6px; + cursor: pointer; + min-width: 62px; +} +.theme-btn:hover { + color: var(--text); + border-color: var(--s-starting); +} + .content { display: flex; flex-direction: column; diff --git a/console/src/theme.test.ts b/console/src/theme.test.ts new file mode 100644 index 0000000..bc5aafc --- /dev/null +++ b/console/src/theme.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { cycleTheme, type Theme } from "./theme"; + +describe("cycleTheme", () => { + it("cycles System → Light → Dark → System", () => { + expect(cycleTheme("system")).toBe("light"); + expect(cycleTheme("light")).toBe("dark"); + expect(cycleTheme("dark")).toBe("system"); + }); + + it("returns to a full loop", () => { + let t: Theme = "system"; + for (let i = 0; i < 3; i++) t = cycleTheme(t); + expect(t).toBe("system"); + }); +}); diff --git a/console/src/theme.ts b/console/src/theme.ts new file mode 100644 index 0000000..d58ab93 --- /dev/null +++ b/console/src/theme.ts @@ -0,0 +1,65 @@ +// Appearance toggle. Studio already renders light + dark via +// `prefers-color-scheme` (auto-follows the OS); this adds a manual override so +// the operator can pin an appearance. The choice lives on +// `document.documentElement[data-theme]` — "light"/"dark" force a palette, and +// its absence ("system") lets the OS media query decide (see styles.css). The +// no-FOUC head script in index.html applies the saved choice before first paint; +// this module owns the runtime toggle + persistence. + +export type Theme = "system" | "light" | "dark"; + +const STORAGE_KEY = "oab-studio.theme"; +const ORDER: readonly Theme[] = ["system", "light", "dark"]; +const LABEL: Record = { + system: "System", + light: "Light", + dark: "Dark", +}; + +// Pure: the next appearance in the System → Light → Dark → System cycle. An +// unknown value falls back into the cycle at System. +export function cycleTheme(current: Theme): Theme { + const i = ORDER.indexOf(current); + return ORDER[(i + 1) % ORDER.length] ?? "system"; +} + +// Read the saved choice, tolerating a missing/garbage value (⇒ "system"). +export function readTheme(): Theme { + try { + const t = localStorage.getItem(STORAGE_KEY); + if (t === "light" || t === "dark" || t === "system") return t; + } catch { + /* storage unavailable — fall through to system */ + } + return "system"; +} + +// Reflect a choice onto the document: "system" removes the attribute so the +// prefers-color-scheme media query applies; otherwise pin the palette. +export function applyTheme(theme: Theme): void { + const root = document.documentElement; + if (theme === "system") root.removeAttribute("data-theme"); + else root.setAttribute("data-theme", theme); +} + +// Wire the toolbar button: show the current choice, and cycle + persist + apply +// on click. Applies the saved choice up front so the button and the document +// agree even if the head script didn't run (e.g. tests / SSR). +export function initThemeToggle(btn: HTMLButtonElement): void { + let current = readTheme(); + const paint = (): void => { + btn.textContent = LABEL[current]; + }; + applyTheme(current); + paint(); + btn.addEventListener("click", () => { + current = cycleTheme(current); + try { + localStorage.setItem(STORAGE_KEY, current); + } catch { + /* storage unavailable — the choice still applies for this session */ + } + applyTheme(current); + paint(); + }); +}