diff --git a/reaction_board/web/static/index.html b/reaction_board/web/static/index.html index 89b69dd..0518631 100644 --- a/reaction_board/web/static/index.html +++ b/reaction_board/web/static/index.html @@ -9,6 +9,7 @@ 咻咻满 Reaction 看板 +
@@ -23,6 +24,7 @@
+
@@ -41,6 +43,7 @@
+
diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index d874cce..a66736c 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -1,5 +1,5 @@ :root { - color-scheme: light dark; + color-scheme: light; --bg: #f7f2ea; --bg-deep: #efe7db; --surface: #fffdf9; @@ -29,32 +29,32 @@ --nav-height: 64px; } -@media (prefers-color-scheme: dark) { - :root { - --bg: #1b1411; - --bg-deep: #140f0c; - --surface: #221a15; - --surface-2: #2a201a; - --surface-3: #342820; - --text: #f3e9dc; - --muted: #c9b6a0; - --faint: #9c8976; - --border: #3a2c22; - --grid: #3a2c22; - --accent: #e9b97c; - --accent-strong: #f6d9ae; - --accent-soft: #e9b97c29; - --accent-contrast: #2a1a10; - --blue: #a8bccc; - --blue-soft: #a8bccc1f; - --pink: #e3958c; - --warning: #e9b97c; - --warning-soft: #e9b97c1f; - --danger: #e3958c; - --success: #a7b98f; - --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); - --shadow-float: 0 16px 40px rgba(0, 0, 0, 0.55); - } +/* 深色配色由 data-theme 决定;该属性在首屏绘制前由 /static/theme.js 写入。 */ +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #1b1411; + --bg-deep: #140f0c; + --surface: #221a15; + --surface-2: #2a201a; + --surface-3: #342820; + --text: #f3e9dc; + --muted: #c9b6a0; + --faint: #9c8976; + --border: #3a2c22; + --grid: #3a2c22; + --accent: #e9b97c; + --accent-strong: #f6d9ae; + --accent-soft: #e9b97c29; + --accent-contrast: #2a1a10; + --blue: #a8bccc; + --blue-soft: #a8bccc1f; + --pink: #e3958c; + --warning: #e9b97c; + --warning-soft: #e9b97c1f; + --danger: #e3958c; + --success: #a7b98f; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-float: 0 16px 40px rgba(0, 0, 0, 0.55); } * { box-sizing: border-box; } @@ -127,6 +127,25 @@ h1, h2, h3, strong, b { font-weight: 600; } font-size: 11px; } .topbar-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; } +/* 主题切换:图标表示当前主题,提示文案说明将要切换到的主题。 */ +.theme-toggle { + flex: 0 0 auto; + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--muted); + cursor: pointer; +} +.theme-toggle:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); +} +.theme-toggle svg { width: 16px; height: 16px; } .topbar-links { display: flex; align-items: center; gap: 6px; } .topbar-links a { padding: 4px 10px; @@ -167,9 +186,6 @@ h1, h2, h3, strong, b { font-weight: 600; } color: var(--accent-contrast); background: var(--accent); } -@media (prefers-color-scheme: dark) { - .preview-banner { color: var(--accent-contrast); background: var(--accent); } -} .main-shell { padding: 12px 12px calc(var(--nav-height) + 28px + env(safe-area-inset-bottom)); diff --git a/reaction_board/web/static/theme.js b/reaction_board/web/static/theme.js new file mode 100644 index 0000000..7f4adb4 --- /dev/null +++ b/reaction_board/web/static/theme.js @@ -0,0 +1,90 @@ +// 主题偏好:默认跟随系统;手动切换后写入 localStorage 并固定。 +// 在 中同步执行,因此在首屏绘制前就写好了 data-theme,不会闪白或闪黑。 +// 服务端 CSP 为 script-src 'self',不允许内联脚本,所以单独成文件。 +(function () { + const KEY = "reaction-board:theme"; + const root = document.documentElement; + const system = window.matchMedia("(prefers-color-scheme: dark)"); + const ICONS = { + light: + '', + dark: '', + }; + + function stored() { + try { + const value = window.localStorage.getItem(KEY); + return value === "dark" || value === "light" ? value : null; + } catch (error) { + return null; // 隐私模式等禁用 localStorage 时按跟随系统处理 + } + } + + function resolve() { + return stored() || (system.matches ? "dark" : "light"); + } + + function syncMeta(theme) { + // 无 media 的 theme-color 位于最前,优先于 index.html 里按系统匹配的两条, + // 这样手动切换后浏览器界面颜色也会跟着变。 + const fallback = theme === "dark" ? "#1b1411" : "#f7f2ea"; + const value = getComputedStyle(root).getPropertyValue("--bg").trim() || fallback; + let meta = document.querySelector('meta[name="theme-color"]:not([media])'); + if (!meta) { + meta = document.createElement("meta"); + meta.name = "theme-color"; + document.head.prepend(meta); + } + meta.content = value; + } + + function syncButtons(theme) { + const target = theme === "dark" ? "浅色" : "深色"; + document.querySelectorAll("[data-theme-toggle]").forEach((button) => { + button.innerHTML = + '`; + button.setAttribute("aria-label", `切换到${target}主题`); + button.title = `切换到${target}主题`; + }); + } + + function apply() { + const theme = resolve(); + root.dataset.theme = theme; + syncMeta(theme); + syncButtons(theme); + } + + function toggle() { + const next = resolve() === "dark" ? "light" : "dark"; + try { + window.localStorage.setItem(KEY, next); + } catch (error) { + // 无法持久化时仍切换当前页面,下次进入回到跟随系统 + } + apply(); + } + + // 此时按钮还没解析出来,先锁定首屏配色。 + apply(); + + function bind() { + document.querySelectorAll("[data-theme-toggle]").forEach((button) => { + button.addEventListener("click", toggle); + }); + apply(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bind, { once: true }); + } else { + bind(); + } + + // 未手动选择时跟随系统主题变化实时切换。 + system.addEventListener("change", () => { + if (!stored()) apply(); + }); +})(); \ No newline at end of file diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 5429a52..5d53fd2 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -87,7 +87,7 @@ def test_readonly_routes_and_static(): assert "text/html" in headers["Content-Type"] assert b"