From 187ef6ae241222038dd233e9ec5d39f95e5ca740 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 29 Aug 2026 12:12:06 +0000 Subject: [PATCH] feat(frontend): put a light/dark/system control in the account menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appearance is a radio group inside the avatar menu rather than a loose button, so arrow keys reach it and the current choice is announced. Picking one keeps the menu open, so the change is visible where it was made. "跟随系统" removes data-theme instead of writing a third value — the media query has nothing to match otherwise. A synchronous script at the top of re-applies a pinned choice before the first paint; going through next/script with any strategy would defer it and bring the flash straight back, so the test asserts a plain script tag. Blocked storage degrades to following the OS instead of throwing. The stored value is browser state, so it is read through useSyncExternalStore rather than synced into React state in an effect, which also trips the cascading-render lint rule. localStorage only fires `storage` in other tabs, so a same-tab write notifies its own listeners and every open tab stays in step. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu --- frontend/DESIGN.md | 13 +++- frontend/src/app/globals.css | 7 ++ frontend/src/app/layout.tsx | 4 + frontend/src/components/app-sidebar.tsx | 3 + .../src/components/theme-preference-menu.tsx | 62 +++++++++++++++ frontend/src/lib/theme-preference.ts | 56 ++++++++++++++ .../tests/theme-preference-contract.test.ts | 77 +++++++++++++++++++ 7 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/theme-preference-menu.tsx create mode 100644 frontend/src/lib/theme-preference.ts create mode 100644 frontend/tests/theme-preference-contract.test.ts 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. */} +