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
+
+
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();
+ });
+}