feat(frontend): put a light/dark/system control in the account menu
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:
+11
-2
@@ -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 `<head>` — 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
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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, /<script dangerouslySetInnerHTML=\{\{ __html: themePreferenceBootScript \}\} \/>/);
|
||||
// A plain <script>, never next/script: any strategy defers it past first
|
||||
// paint and the flash comes straight back.
|
||||
const injection = layout.slice(layout.indexOf("<script dangerouslySetInnerHTML"));
|
||||
assert.doesNotMatch(injection.slice(0, 200), /strategy=/);
|
||||
assert.ok(
|
||||
layout.indexOf("themePreferenceBootScript") < layout.indexOf("<head>") + 400,
|
||||
"the boot script must sit at the top of <head>",
|
||||
);
|
||||
assert.match(themePreferenceBootScript, new RegExp(`localStorage\\.getItem\\("${THEME_STORAGE_KEY}"\\)`));
|
||||
assert.match(themePreferenceBootScript, /t==="dark"\|\|t==="light"/);
|
||||
assert.doesNotMatch(themePreferenceBootScript, /"system"/);
|
||||
// Blocked storage must not throw before the app renders.
|
||||
assert.match(themePreferenceBootScript, /^try\{/);
|
||||
assert.match(themePreferenceBootScript, /catch\(e\)\{\}$/);
|
||||
});
|
||||
|
||||
test("the boot script and the runtime share one storage key", () => {
|
||||
// Two literals would drift and silently lose the user's choice.
|
||||
assert.equal((lib.match(/"jyotisha-theme"/g) ?? []).length, 1);
|
||||
assert.match(lib, /const THEME_STORAGE_KEY = "jyotisha-theme"/);
|
||||
});
|
||||
|
||||
test("system clears the attribute rather than writing a third value", () => {
|
||||
assert.match(lib, /if \(preference === "system"\) delete root\.dataset\.theme;/);
|
||||
assert.match(lib, /localStorage\.removeItem\(THEME_STORAGE_KEY\)/);
|
||||
});
|
||||
|
||||
test("stored preference is read as external state, not effect-synced React state", () => {
|
||||
// Reading it into state inside an effect both trips the cascading-render rule
|
||||
// and misses changes made in another tab.
|
||||
assert.match(menu, /useSyncExternalStore\(/);
|
||||
assert.match(menu, /\(\) => "system" as ThemePreference/);
|
||||
assert.doesNotMatch(menu, /useState|useEffect/);
|
||||
// Same-tab writes need an explicit notify: `storage` only fires elsewhere.
|
||||
assert.match(lib, /window\.addEventListener\("storage", onChange\)/);
|
||||
assert.match(lib, /for \(const listener of listeners\) listener\(\);/);
|
||||
});
|
||||
|
||||
test("the control lives in the account menu and keeps menu semantics", () => {
|
||||
assert.match(sidebar, /<ThemePreferenceMenu \/>/);
|
||||
assert.match(sidebar, /<Menu\.Separator className="account-menu-separator" \/>\s*<ThemePreferenceMenu \/>/);
|
||||
assert.match(menu, /Menu\.RadioGroup/);
|
||||
assert.match(menu, /Menu\.RadioItem/);
|
||||
assert.match(menu, /Menu\.RadioItemIndicator/);
|
||||
// Picking a theme should not slam the menu shut before the change is visible.
|
||||
assert.match(menu, /closeOnClick=\{false\}/);
|
||||
assert.match(menu, /外观/);
|
||||
for (const label of ["浅色", "深色", "跟随系统"]) assert.match(menu, new RegExp(label));
|
||||
assert.match(globalStyles, /\.theme-menu-check \{[^}]*color: var\(--color-action\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user