diff --git a/internal/viewer/server_extra_test.go b/internal/viewer/server_extra_test.go
index d42adf703..f0c4449dd 100644
--- a/internal/viewer/server_extra_test.go
+++ b/internal/viewer/server_extra_test.go
@@ -343,6 +343,33 @@ func TestSurfaceCSS_LightSurfaceIsWhite(t *testing.T) {
}
}
+func TestThemeToggle_IsAvailableInAppHeader(t *testing.T) {
+ head, err := assets.ReadFile("templates/app-header.html")
+ if err != nil {
+ t.Fatalf("read templates/app-header.html: %v", err)
+ }
+ if !strings.Contains(string(head), `data-theme-toggle`) {
+ t.Fatal("app-header.html is missing the theme toggle button hook")
+ }
+ if !strings.Contains(string(head), "☀") && !strings.Contains(string(head), "Toggle color theme") {
+ t.Fatal("app-header.html does not expose a visible theme toggle")
+ }
+}
+
+func TestThemeToggleCSS_UsesDataThemeOverride(t *testing.T) {
+ css, err := assets.ReadFile("static/style.css")
+ if err != nil {
+ t.Fatalf("read static/style.css: %v", err)
+ }
+ text := string(css)
+ if !strings.Contains(text, "body[data-theme=\"light\"]") || !strings.Contains(text, "body[data-theme=\"dark\"]") {
+ t.Fatal("style.css is missing body[data-theme=\"light\"] / body[data-theme=\"dark\"] overrides")
+ }
+ if !strings.Contains(text, "--bg: #ffffff;") || !strings.Contains(text, "--bg: #000000;") {
+ t.Fatal("style.css is missing the light/dark page background tokens used by the theme toggle")
+ }
+}
+
// TestTextTokens_MeetWCAGAAOnPageBackground holds the muted and secondary
// text tokens to WCAG AA (4.5:1) against each theme's *page* background, so
// future palette tweaks cannot quietly drop the low-emphasis labels back
diff --git a/internal/viewer/static/a11y.js b/internal/viewer/static/a11y.js
index 8d1d35264..625d83082 100644
--- a/internal/viewer/static/a11y.js
+++ b/internal/viewer/static/a11y.js
@@ -3,6 +3,40 @@
// Small accessibility helpers shared by the viewer pages.
(() => {
+ const themeKey = "ocr-viewer-theme";
+ const applyTheme = (theme) => {
+ const nextTheme = theme === "light" ? "light" : "dark";
+ document.body.dataset.theme = nextTheme;
+ const button = document.querySelector("[data-theme-toggle]");
+ if (button) {
+ button.setAttribute("aria-pressed", String(nextTheme === "light"));
+ button.textContent = nextTheme === "light" ? "☀" : "☾";
+ button.title = nextTheme === "light" ? "Switch to dark mode" : "Switch to light mode";
+ }
+ try {
+ localStorage.setItem(themeKey, nextTheme);
+ } catch {
+ // Storage may be unavailable; the page still keeps the in-memory mode.
+ }
+ };
+
+ const storedTheme = (() => {
+ try {
+ return localStorage.getItem(themeKey);
+ } catch {
+ return null;
+ }
+ })();
+ applyTheme(storedTheme === "light" ? "light" : "dark");
+
+ const button = document.querySelector("[data-theme-toggle]");
+ if (button) {
+ button.addEventListener("click", () => {
+ const nextTheme = document.body.dataset.theme === "light" ? "dark" : "light";
+ applyTheme(nextTheme);
+ });
+ }
+
// The scrollable table wrappers are focusable regions, but not every
// browser scrolls a focused container with the arrow keys; route the
// keys through scrollBy so keyboard users can reach overflowing columns
diff --git a/internal/viewer/static/style.css b/internal/viewer/static/style.css
index f8c4a7345..09e98382e 100644
--- a/internal/viewer/static/style.css
+++ b/internal/viewer/static/style.css
@@ -12,12 +12,363 @@ Copyright 2026 alibaba/open-code-review Contributors
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, "Liberation Mono", ui-monospace, monospace;
/* Surfaces — neutral layers from the viewer redesign mockups */
+ --bg: #000000;
+ --surface: #0a0a0a;
+ --surface-alt: #141414;
+ --surface-inset: #111111;
+ --response-bg: var(--surface);
+
+ --text: rgba(255, 255, 255, 0.8);
+ --text-strong: rgba(255, 255, 255, 0.9);
+ --text-secondary: rgba(255, 255, 255, 0.6);
+ --text-muted: rgba(255, 255, 255, 0.47);
+ --text-faint: var(--text-muted);
+
+ --border: rgba(255, 255, 255, 0.16);
+ --border-subtle: rgba(255, 255, 255, 0.08);
+
+ --accent: #2bde5e;
+ --accent-hover: #60e686;
+ --accent-soft: rgba(43, 222, 94, 0.1);
+ --accent-glow: rgba(43, 222, 94, 0.12);
+ --link: var(--accent);
+
+ --code-bg: var(--surface-inset);
+ --inline-code-bg: var(--surface-alt);
+
+ --row-border: #292929;
+ --btn-border: #3d3d3d;
+ --input-bg: rgba(255, 255, 255, 0.08);
+ --input-hover: rgba(255, 255, 255, 0.12);
+ --tag-bg: rgba(255, 255, 255, 0.06);
+ --tag-hover: rgba(255, 255, 255, 0.1);
+ --hover-row: rgba(255, 255, 255, 0.03);
+ --marked-text: #9e9e9e;
+ --pagination-active-border: #3d3d3d;
+ --pagination-active-text: #ffffff;
+ --scroll-thumb: rgba(255, 255, 255, 0.16);
+ --code-red-bg: rgba(227, 61, 73, 0.12);
+ --code-green-bg: rgba(43, 222, 94, 0.08);
+
+ --task-main: #818cf8;
+ --task-plan: #a78bfa;
+ --task-memory: #8b949e;
+ --task-relocation: #ea580c;
+ --task-grouping: #22d3ee;
+ --task-default: #57606a;
+ --tool-name: #a78bfa;
+ --danger: #f87171;
+
+ --badge-model-bg: rgba(129, 140, 248, 0.12); --badge-model-fg: #a5b4fc;
+ --badge-tokens-bg: rgba(52, 211, 153, 0.12); --badge-tokens-fg: #6ee7b7;
+ --badge-duration-bg: rgba(251, 191, 36, 0.12); --badge-duration-fg: #fcd34d;
+ --badge-error-bg: rgba(248, 113, 113, 0.12); --badge-error-fg: #fca5a5;
+ --badge-neutral-bg: var(--surface-alt);
+ --badge-neutral-fg: var(--text-secondary);
+
+ --severity-critical-bg: var(--badge-error-bg);
+ --severity-critical-fg: var(--badge-error-fg);
+ --severity-high-bg: rgba(234, 88, 12, 0.12);
+ --severity-high-fg: #fdba74;
+ --severity-medium-bg: rgba(251, 191, 36, 0.12);
+ --severity-medium-fg: #fcd34d;
+ --severity-low-bg: var(--badge-neutral-bg);
+ --severity-low-fg: var(--badge-neutral-fg);
+
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-7: 1.75rem;
+ --space-8: 2rem;
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
+ --radius: 10px;
+ --radius-sm: 6px;
+ --radius-xs: 4px;
+ --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+body[data-theme="light"] {
--bg: #ffffff;
--surface: #ffffff;
--surface-alt: #f5f5f5;
--surface-inset: #f0f0f0;
--response-bg: var(--surface);
+ --text: rgba(0, 0, 0, 0.77);
+ --text-strong: rgba(0, 0, 0, 0.87);
+ --text-secondary: rgba(0, 0, 0, 0.62);
+ --text-muted: rgba(0, 0, 0, 0.55);
+ --text-faint: var(--text-muted);
+
+ --border: rgba(0, 0, 0, 0.08);
+ --border-subtle: rgba(0, 0, 0, 0.06);
+
+ --accent: #17cb4b;
+ --accent-hover: #14a83e;
+ --accent-soft: rgba(23, 203, 75, 0.1);
+ --accent-glow: rgba(23, 203, 75, 0.16);
+ --link: #177d35;
+
+ --code-bg: var(--surface-inset);
+ --inline-code-bg: var(--surface-alt);
+
+ --row-border: #ebebeb;
+ --btn-border: rgba(0, 0, 0, 0.16);
+ --input-bg: rgba(0, 0, 0, 0.04);
+ --input-hover: rgba(0, 0, 0, 0.08);
+ --tag-bg: rgba(0, 0, 0, 0.05);
+ --tag-hover: rgba(0, 0, 0, 0.08);
+ --hover-row: rgba(0, 0, 0, 0.03);
+ --marked-text: rgba(0, 0, 0, 0.55);
+ --pagination-active-border: rgba(0, 0, 0, 0.24);
+ --pagination-active-text: rgba(0, 0, 0, 0.87);
+ --scroll-thumb: rgba(0, 0, 0, 0.16);
+ --code-red-bg: rgba(227, 61, 73, 0.08);
+ --code-green-bg: rgba(23, 203, 75, 0.08);
+
+ --task-main: #4f46e5;
+ --task-plan: #7c3aed;
+ --task-memory: #8b949e;
+ --task-relocation: #ea580c;
+ --task-grouping: #0891b2;
+ --task-default: #57606a;
+ --tool-name: #7c3aed;
+ --danger: #dc2626;
+
+ --badge-model-bg: #eef2ff; --badge-model-fg: #3730a3;
+ --badge-tokens-bg: #ecfdf5; --badge-tokens-fg: #065f46;
+ --badge-duration-bg: #fefce8; --badge-duration-fg: #854d0e;
+ --badge-error-bg: #fef2f2; --badge-error-fg: #b91c1c;
+ --badge-neutral-bg: var(--surface-inset);
+ --badge-neutral-fg: var(--text-secondary);
+
+ --severity-critical-bg: var(--badge-error-bg);
+ --severity-critical-fg: var(--badge-error-fg);
+ --severity-high-bg: #fff7ed;
+ --severity-high-fg: #c2410c;
+ --severity-medium-bg: #fefce8;
+ --severity-medium-fg: #a16207;
+ --severity-low-bg: var(--badge-neutral-bg);
+ --severity-low-fg: var(--badge-neutral-fg);
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.06), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
+}
+
+body[data-theme="dark"] {
+ --bg: #000000;
+ --surface: #0a0a0a;
+ --surface-alt: #141414;
+ --surface-inset: #111111;
+ --response-bg: var(--surface);
+
+ --text: rgba(255, 255, 255, 0.8);
+ --text-strong: rgba(255, 255, 255, 0.9);
+ --text-secondary: rgba(255, 255, 255, 0.6);
+ --text-muted: rgba(255, 255, 255, 0.47);
+ --text-faint: var(--text-muted);
+
+ --border: rgba(255, 255, 255, 0.16);
+ --border-subtle: rgba(255, 255, 255, 0.08);
+
+ --accent: #2bde5e;
+ --accent-hover: #60e686;
+ --accent-soft: rgba(43, 222, 94, 0.1);
+ --accent-glow: rgba(43, 222, 94, 0.12);
+ --link: var(--accent);
+
+ --code-bg: var(--surface-inset);
+ --inline-code-bg: var(--surface-alt);
+
+ --row-border: #292929;
+ --btn-border: #3d3d3d;
+ --input-bg: rgba(255, 255, 255, 0.08);
+ --input-hover: rgba(255, 255, 255, 0.12);
+ --tag-bg: rgba(255, 255, 255, 0.06);
+ --tag-hover: rgba(255, 255, 255, 0.1);
+ --hover-row: rgba(255, 255, 255, 0.03);
+ --marked-text: #9e9e9e;
+ --pagination-active-border: #3d3d3d;
+ --pagination-active-text: #ffffff;
+ --scroll-thumb: rgba(255, 255, 255, 0.16);
+ --code-red-bg: rgba(227, 61, 73, 0.12);
+ --code-green-bg: rgba(43, 222, 94, 0.08);
+
+ --task-main: #818cf8;
+ --task-plan: #a78bfa;
+ --task-memory: #8b949e;
+ --task-relocation: #ea580c;
+ --task-grouping: #22d3ee;
+ --task-default: #57606a;
+ --tool-name: #a78bfa;
+ --danger: #f87171;
+
+ --badge-model-bg: rgba(129, 140, 248, 0.12); --badge-model-fg: #a5b4fc;
+ --badge-tokens-bg: rgba(52, 211, 153, 0.12); --badge-tokens-fg: #6ee7b7;
+ --badge-duration-bg: rgba(251, 191, 36, 0.12); --badge-duration-fg: #fcd34d;
+ --badge-error-bg: rgba(248, 113, 113, 0.12); --badge-error-fg: #fca5a5;
+ --badge-neutral-bg: var(--surface-alt);
+ --badge-neutral-fg: var(--text-secondary);
+
+ --severity-critical-bg: var(--badge-error-bg);
+ --severity-critical-fg: var(--badge-error-fg);
+ --severity-high-bg: rgba(234, 88, 12, 0.12);
+ --severity-high-fg: #fdba74;
+ --severity-medium-bg: rgba(251, 191, 36, 0.12);
+ --severity-medium-fg: #fcd34d;
+ --severity-low-bg: var(--badge-neutral-bg);
+ --severity-low-fg: var(--badge-neutral-fg);
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #000000;
+ --surface: #0a0a0a;
+ --surface-alt: #141414;
+ --surface-inset: #111111;
+
+ --text: rgba(255, 255, 255, 0.8);
+ --text-strong: rgba(255, 255, 255, 0.9);
+ --text-secondary: rgba(255, 255, 255, 0.6);
+ --text-muted: rgba(255, 255, 255, 0.47);
+
+ --border: rgba(255, 255, 255, 0.16);
+ --border-subtle: rgba(255, 255, 255, 0.08);
+
+ --accent: #2bde5e;
+ --accent-hover: #60e686;
+ --accent-soft: rgba(43, 222, 94, 0.1);
+ --accent-glow: rgba(43, 222, 94, 0.12);
+ /* On dark surfaces the bright accent green already clears AA as text. */
+ --link: var(--accent);
+
+ --row-border: #292929;
+ --btn-border: #3d3d3d;
+ --input-bg: rgba(255, 255, 255, 0.08);
+ --input-hover: rgba(255, 255, 255, 0.12);
+ --tag-bg: rgba(255, 255, 255, 0.06);
+ --tag-hover: rgba(255, 255, 255, 0.1);
+ --hover-row: rgba(255, 255, 255, 0.03);
+ --marked-text: #9e9e9e;
+ --pagination-active-border: #3d3d3d;
+ --pagination-active-text: #ffffff;
+ --scroll-thumb: rgba(255, 255, 255, 0.16);
+ --code-red-bg: rgba(227, 61, 73, 0.12);
+ --code-green-bg: rgba(43, 222, 94, 0.08);
+
+ --task-main: #818cf8;
+ --task-plan: #a78bfa;
+ --task-grouping: #22d3ee;
+ --task-default: #6b7394;
+ --tool-name: #a78bfa;
+ --danger: #f87171;
+
+ --badge-model-bg: rgba(129, 140, 248, 0.12); --badge-model-fg: #a5b4fc;
+ --badge-tokens-bg: rgba(52, 211, 153, 0.12); --badge-tokens-fg: #6ee7b7;
+ --badge-duration-bg: rgba(251, 191, 36, 0.12); --badge-duration-fg: #fcd34d;
+ --badge-error-bg: rgba(248, 113, 113, 0.12); --badge-error-fg: #fca5a5;
+ --badge-neutral-bg: var(--surface-alt);
+
+ --severity-high-bg: rgba(234, 88, 12, 0.12);
+ --severity-high-fg: #fdba74;
+ --severity-medium-bg: rgba(251, 191, 36, 0.12);
+ --severity-medium-fg: #fcd34d;
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
+ }
+}
+
+/* ── Reset & Base ── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: var(--font);
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.65;
+ padding-bottom: 4rem;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code, pre {
+ font-family: var(--mono);
+}
+
+/* ── Navigation ── */
+nav.breadcrumb {
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ padding: 0 var(--space-6);
+ height: 52px;
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ font-size: 0.875rem;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ backdrop-filter: blur(12px) saturate(180%);
+ -webkit-backdrop-filter: blur(12px) saturate(180%);
+}
+
+nav.breadcrumb .theme-toggle {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 36px;
+ height: 30px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--surface-alt);
+ color: var(--text-strong);
+ cursor: pointer;
+ font-size: 0.85rem;
+ transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
+}
+
+nav.breadcrumb .theme-toggle:hover,
+nav.breadcrumb .theme-toggle:focus-visible {
+ background: var(--surface-inset);
+ border-color: var(--border);
+ outline: none;
+ transform: translateY(-1px);
+}
+
+@supports (backdrop-filter: blur(12px)) and (background: color-mix(in srgb, red 50%, blue)) {
+ nav.breadcrumb {
+ background: color-mix(in srgb, var(--surface) 85%, transparent);
+ }
+}
+
+nav.breadcrumb .nav-brand {
+ font-weight: 700;
+ font-size: 0.9rem;
+ letter-spacing: -0.02em;
+ color: var(--text-strong);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ transition: color var(--transition);
+}
+nav.breadcrumb a.nav-brand:hover { color: var(--link); }
+
+nav.breadcrumb .nav-brand .brand-icon {
/* Text */
--text: rgba(0, 0, 0, 0.77);
--text-strong: rgba(0, 0, 0, 0.87);
diff --git a/internal/viewer/templates/app-header.html b/internal/viewer/templates/app-header.html
index d3127613d..f90ff5fbd 100644
--- a/internal/viewer/templates/app-header.html
+++ b/internal/viewer/templates/app-header.html
@@ -3,7 +3,7 @@
Copyright 2026 alibaba/open-code-review Contributors
*/}}
{{define "app-header"}}
-
+