feat(frontend): put a light/dark/system control in the account menu
Independent Staging Quality Gate / validate (push) Successful in 10m4s
Independent Staging Quality Gate / publish (push) Successful in 24m59s

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 <head> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
This commit is contained in:
Jesse_Chen
2026-08-29 12:12:06 +00:00
co-authored by Claude Opus 5
parent c2d705ef6e
commit 187ef6ae24
7 changed files with 220 additions and 2 deletions
+7
View File
@@ -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); }
+4
View File
@@ -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 (
<html lang="zh-CN" className={inter.variable} suppressHydrationWarning>
<head>
{/* Synchronous on purpose: a pinned theme must be on <html> before the
first paint, or the page flashes the other theme on every load. */}
<script dangerouslySetInnerHTML={{ __html: themePreferenceBootScript }} />
{enableReactDevTools && (
<>
<Script
+3
View File
@@ -36,6 +36,7 @@ import {
type SidebarSession,
type SidebarSessionControls,
} from "@/components/sidebar-session-row";
import { ThemePreferenceMenu } from "@/components/theme-preference-menu";
import { UserAvatar } from "@/components/user-avatar";
import type { BeamAvatar } from "@/lib/beam-avatar";
@@ -306,6 +307,8 @@ export function AppSidebar({
<Gift aria-hidden="true" /><span></span><small>{account.credits} </small>
</Menu.Item>
<Menu.Separator className="account-menu-separator" />
<ThemePreferenceMenu />
<Menu.Separator className="account-menu-separator" />
<Menu.Item className="account-menu-item account-menu-danger" onClick={onOpenLogout}>
<LogOut aria-hidden="true" /><span>退</span>
</Menu.Item>
@@ -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 <Sun aria-hidden="true" />;
if (value === "dark") return <Moon aria-hidden="true" />;
return <Monitor aria-hidden="true" />;
}
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 (
<Menu.Group className="theme-menu">
<Menu.GroupLabel className="theme-menu-label"></Menu.GroupLabel>
<Menu.RadioGroup
value={preference}
onValueChange={(next) => {
applyThemePreference(next as ThemePreference);
}}
>
{options.map((option) => (
<Menu.RadioItem
className="account-menu-item theme-menu-item"
closeOnClick={false}
key={option.value}
value={option.value}
>
<OptionIcon value={option.value} />
<span>{option.label}</span>
<Menu.RadioItemIndicator className="theme-menu-check">
<Check aria-hidden="true" />
</Menu.RadioItemIndicator>
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</Menu.Group>
);
}
+56
View File
@@ -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 <head>, 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();
}