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"}} - +