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. */} +