diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 549a3a84..1968cb4f 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -115,8 +115,17 @@ must increase in lightness in floor → canvas → muted → strong order.
properly means switching antd to `theme.darkAlgorithm` as well, and that is a
separate change.
-**Not built yet:** a visible light/dark/system control. The `data-theme` hook
-exists so a toggle can be added without touching any of the styling above.
+**The control** is a radio group inside the account menu (avatar → 外观), so it
+keeps menu semantics: arrow keys reach it and the current choice is announced.
+Three options, matching the three states — 浅色, 深色, 跟随系统. Picking one does
+not close the menu, so the change is visible where it was made.
+
+"跟随系统" **removes** `data-theme` rather than writing a third value; the media
+query has nothing to match otherwise. The choice is stored under `jyotisha-theme`
+and re-applied by a synchronous script at the top of `
` — it must not be
+deferred, or every load flashes the other theme before hydration. Blocked storage
+degrades to following the OS. The preference is per-device, not per-account, and
+is read through an external store so a change in one tab reaches the others.
## 3. Typography
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index db7cc647..f9b32476 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -1541,6 +1541,13 @@ button:disabled { cursor: default; opacity: .45; }
.account-menu-item > svg:last-child { width: 16px; height: 16px; }
.account-menu-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-body-sm); }
.account-menu-item > small { color: var(--color-ink-tertiary); font-size: var(--type-caption); font-variant-numeric: tabular-nums; }
+/* Appearance sits inside the account menu as a radio group so it keeps menu
+ semantics: arrow keys move through it and the current choice is announced. */
+.theme-menu { display: grid; gap: 1px; padding: var(--space-1) 0; }
+.theme-menu-label { padding: var(--space-1) var(--space-3); color: var(--color-ink-tertiary); font-size: var(--type-caption); font-weight: 500; }
+.theme-menu-item { cursor: pointer; }
+.theme-menu-check { display: inline-grid; margin-left: auto; place-items: center; color: var(--color-action); }
+.theme-menu-check svg { width: 15px; height: 15px; }
.account-menu-separator { height: 1px; margin: var(--space-2) var(--space-3); background: var(--color-border); }
.account-menu-danger, .account-menu-danger > svg { color: var(--color-danger); }
.account-menu-danger[data-highlighted] { background: var(--color-danger-muted); }
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 14e13f9c..61da1139 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import { StaleClientRecovery } from "@/components/stale-client-recovery";
+import { themePreferenceBootScript } from "@/lib/theme-preference";
const inter = Inter({
subsets: ["latin"],
@@ -29,6 +30,9 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
return (
+ {/* Synchronous on purpose: a pinned theme must be on before the
+ first paint, or the page flashes the other theme on every load. */}
+
{enableReactDevTools && (
<>
兑换点数{account.credits} 点
+
+
退出登录
diff --git a/frontend/src/components/theme-preference-menu.tsx b/frontend/src/components/theme-preference-menu.tsx
new file mode 100644
index 00000000..4880d4f1
--- /dev/null
+++ b/frontend/src/components/theme-preference-menu.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { Menu } from "@base-ui/react/menu";
+import { Check, Monitor, Moon, Sun } from "lucide-react";
+import { useSyncExternalStore } from "react";
+
+import {
+ applyThemePreference,
+ readThemePreference,
+ subscribeThemePreference,
+ type ThemePreference,
+} from "@/lib/theme-preference";
+
+const options: ReadonlyArray<{ value: ThemePreference; label: string }> = [
+ { value: "light", label: "浅色" },
+ { value: "dark", label: "深色" },
+ { value: "system", label: "跟随系统" },
+];
+
+function OptionIcon({ value }: { readonly value: ThemePreference }) {
+ if (value === "light") return ;
+ if (value === "dark") return ;
+ return ;
+}
+
+export function ThemePreferenceMenu() {
+ // The stored choice is browser state, not React state: read it through the
+ // external store so the server snapshot stays "system" (no hydration
+ // mismatch) and a change in another tab lands here too.
+ const preference = useSyncExternalStore(
+ subscribeThemePreference,
+ readThemePreference,
+ () => "system" as ThemePreference,
+ );
+
+ return (
+
+ 外观
+ {
+ applyThemePreference(next as ThemePreference);
+ }}
+ >
+ {options.map((option) => (
+
+
+ {option.label}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/lib/theme-preference.ts b/frontend/src/lib/theme-preference.ts
new file mode 100644
index 00000000..712319a2
--- /dev/null
+++ b/frontend/src/lib/theme-preference.ts
@@ -0,0 +1,56 @@
+export type ThemePreference = "system" | "light" | "dark";
+
+export const THEME_STORAGE_KEY = "jyotisha-theme";
+
+export function isThemePreference(value: unknown): value is ThemePreference {
+ return value === "system" || value === "light" || value === "dark";
+}
+
+/**
+ * Runs synchronously in , before the first paint, so a pinned theme never
+ * flashes the other one on load. It only ever writes `data-theme`; "system" is
+ * the absence of the attribute, which is what the CSS media query expects.
+ * Kept as one exported string so the boot script and the runtime below cannot
+ * drift apart on the storage key.
+ */
+export const themePreferenceBootScript =
+ `try{var t=localStorage.getItem("${THEME_STORAGE_KEY}");`
+ + `if(t==="dark"||t==="light"){document.documentElement.dataset.theme=t}}catch(e){}`;
+
+export function readThemePreference(): ThemePreference {
+ try {
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
+ return isThemePreference(stored) ? stored : "system";
+ } catch {
+ // Private mode or blocked storage: fall back to following the OS.
+ return "system";
+ }
+}
+
+const listeners = new Set<() => void>();
+
+/**
+ * `storage` only fires in *other* tabs, so a local change has to notify this one
+ * explicitly. Subscribing to both keeps every open tab in step.
+ */
+export function subscribeThemePreference(onChange: () => void): () => void {
+ listeners.add(onChange);
+ window.addEventListener("storage", onChange);
+ return () => {
+ listeners.delete(onChange);
+ window.removeEventListener("storage", onChange);
+ };
+}
+
+export function applyThemePreference(preference: ThemePreference): void {
+ const root = document.documentElement;
+ if (preference === "system") delete root.dataset.theme;
+ else root.dataset.theme = preference;
+ try {
+ if (preference === "system") localStorage.removeItem(THEME_STORAGE_KEY);
+ else localStorage.setItem(THEME_STORAGE_KEY, preference);
+ } catch {
+ // The choice still applies to this page; it just will not survive a reload.
+ }
+ for (const listener of listeners) listener();
+}
diff --git a/frontend/tests/theme-preference-contract.test.ts b/frontend/tests/theme-preference-contract.test.ts
new file mode 100644
index 00000000..cc6bcb2c
--- /dev/null
+++ b/frontend/tests/theme-preference-contract.test.ts
@@ -0,0 +1,77 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import {
+ THEME_STORAGE_KEY,
+ isThemePreference,
+ themePreferenceBootScript,
+} from "../src/lib/theme-preference.ts";
+
+const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
+const menu = readFileSync(new URL("../src/components/theme-preference-menu.tsx", import.meta.url), "utf8");
+const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
+const lib = readFileSync(new URL("../src/lib/theme-preference.ts", import.meta.url), "utf8");
+const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
+
+test("only the three preferences are accepted", () => {
+ for (const value of ["system", "light", "dark"]) assert.equal(isThemePreference(value), true);
+ for (const value of ["", "Dark", "auto", null, undefined, 0, {}]) {
+ assert.equal(isThemePreference(value), false);
+ }
+});
+
+test("the boot script runs before paint and only ever pins light or dark", () => {
+ // "system" is the absence of data-theme — that is what the CSS media query
+ // reads. Writing data-theme="system" would match neither dark block.
+ assert.match(layout, //);
+ // A plain