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"