Skip to content
Merged
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
13 changes: 13 additions & 0 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OAB Studio — Console</title>
<link rel="stylesheet" href="/src/styles.css" />
<!-- Apply the saved appearance before first paint (no flash). "system" /
unset leaves data-theme off so prefers-color-scheme wins. -->
<script>
(function () {
try {
var t = localStorage.getItem("oab-studio.theme");
if (t === "light" || t === "dark") {
document.documentElement.setAttribute("data-theme", t);
}
} catch (e) {}
})();
</script>
</head>
<body>
<header class="topbar">
Expand All @@ -14,6 +26,7 @@
<div class="meta">
<span class="cluster" id="cluster-label"></span>
<span class="status" id="poll-status"></span>
<button class="theme-btn" id="theme-btn" type="button" title="Appearance — click to cycle System / Light / Dark">System</button>
<button class="update-btn" id="update-btn" hidden>Check for updates</button>
</div>
</header>
Expand Down
5 changes: 5 additions & 0 deletions console/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -697,6 +698,10 @@ async function boot(): Promise<void> {
});
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);
Expand Down
44 changes: 43 additions & 1 deletion console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions console/src/theme.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
65 changes: 65 additions & 0 deletions console/src/theme.ts
Original file line number Diff line number Diff line change
@@ -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<Theme, string> = {
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();
});
}
Loading