feat(web): dark mode (system/light/dark) via Seed color-mode + profile toggle

This commit is contained in:
Munkherdene 2026-08-22 23:19:31 +08:00
parent 245ef6ea03
commit 972ac65a2b
6 changed files with 180 additions and 2 deletions

View file

@ -2,9 +2,31 @@ import "./globals.css";
export const metadata = { title: "Mercury" };
// Runs before paint (in <head>, ahead of hydration) to set the Seed
// color-mode attribute from the persisted preference, avoiding a
// flash-of-incorrect-theme: the server always renders "system" (no
// localStorage access during SSR), so without this the client would briefly
// paint light/system before this script could run post-hydration.
const THEME_INIT_SCRIPT = `
(function () {
try {
var v = localStorage.getItem("mercury.theme");
var mode = v === "light" ? "light-only" : v === "dark" ? "dark-only" : "system";
document.documentElement.setAttribute("data-seed-color-mode", mode);
} catch (e) {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="mn" data-seed data-seed-color-mode="light-only">
// suppressHydrationWarning: the inline script below intentionally
// rewrites data-seed-color-mode before hydration (to the persisted
// preference), so it will legitimately differ from this "system"
// server-rendered value — React should not warn about or "fix" that.
<html lang="mn" data-seed data-seed-color-mode="system" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body>{children}</body>
</html>
);

View file

@ -0,0 +1,49 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { it, expect, beforeEach } from "vitest";
import { ThemeToggle, getStoredTheme } from "./ThemeToggle";
beforeEach(() => {
try {
localStorage.clear();
} catch {}
document.documentElement.removeAttribute("data-seed-color-mode");
});
it("defaults to system, and picks the persisted choice back up on remount", () => {
expect(getStoredTheme()).toBe("system");
render(<ThemeToggle />);
expect(screen.getByText("Систем")).toBeInTheDocument();
expect(screen.getByText("Гэрэл")).toBeInTheDocument();
expect(screen.getByText("Харанхуй")).toBeInTheDocument();
cleanup();
localStorage.setItem("mercury.theme", "dark");
render(<ThemeToggle />);
const darkInput = screen.getByText("Харанхуй").closest("label")!.querySelector("input")!;
expect(darkInput).toBeChecked();
});
it("selecting dark persists the choice and flips the live data-seed-color-mode attribute", () => {
render(<ThemeToggle />);
const darkInput = screen.getByText("Харанхуй").closest("label")!.querySelector("input")!;
fireEvent.click(darkInput);
expect(getStoredTheme()).toBe("dark");
expect(document.documentElement.getAttribute("data-seed-color-mode")).toBe("dark-only");
});
it("selecting light and back to system round-trips through localStorage + the html attribute", () => {
render(<ThemeToggle />);
const lightInput = screen.getByText("Гэрэл").closest("label")!.querySelector("input")!;
fireEvent.click(lightInput);
expect(getStoredTheme()).toBe("light");
expect(document.documentElement.getAttribute("data-seed-color-mode")).toBe("light-only");
const systemInput = screen.getByText("Систем").closest("label")!.querySelector("input")!;
fireEvent.click(systemInput);
expect(getStoredTheme()).toBe("system");
expect(document.documentElement.getAttribute("data-seed-color-mode")).toBe("system");
});

87
src/ds/ThemeToggle.tsx Normal file
View file

@ -0,0 +1,87 @@
"use client";
import * as React from "react";
import {
SegmentedControlRoot,
SegmentedControlItem,
SegmentedControlItemHiddenInput,
SegmentedControlIndicator,
} from "@seed-design/react";
/** "system" follows the OS via prefers-color-scheme; "light"/"dark" pin it. */
export type MercuryTheme = "system" | "light" | "dark";
const STORAGE_KEY = "mercury.theme";
/** Mirrors the inline script in app/layout.tsx (kept in sync manually the
* script must stay dependency-free/pre-hydration, so it can't import this). */
function toColorMode(theme: MercuryTheme): "system" | "light-only" | "dark-only" {
if (theme === "light") return "light-only";
if (theme === "dark") return "dark-only";
return "system";
}
export function getStoredTheme(): MercuryTheme {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === "light" || v === "dark" || v === "system") return v;
} catch {
// localStorage unavailable (private mode, SSR) — fall through to default.
}
return "system";
}
function persistTheme(theme: MercuryTheme): void {
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
// Best-effort; the toggle still works for the current page load.
}
}
function applyTheme(theme: MercuryTheme): void {
if (typeof document === "undefined") return;
document.documentElement.setAttribute("data-seed-color-mode", toColorMode(theme));
}
export interface ThemeToggleProps {
labels?: { system: string; light: string; dark: string };
}
const DEFAULT_LABELS = { system: "Систем", light: "Гэрэл", dark: "Харанхуй" };
/** 3-way Систем / Гэрэл / Харанхуй display-mode control. Updates
* localStorage and the live `data-seed-color-mode` attribute on `<html>`
* immediately, so every `--seed-color-*` token flips without a reload. */
export function ThemeToggle({ labels = DEFAULT_LABELS }: ThemeToggleProps) {
// Lazy initializer runs on the client at mount (same pattern as
// HideAmountsToggle/isHidden) so it picks up the real preference
// immediately — layout.tsx's inline script has already applied it to the
// DOM before this ever mounts, so both agree from the first paint.
const [theme, setTheme] = React.useState<MercuryTheme>(() => getStoredTheme());
function handleChange(next: string) {
const nextTheme = next as MercuryTheme;
setTheme(nextTheme);
persistTheme(nextTheme);
applyTheme(nextTheme);
}
return (
<SegmentedControlRoot value={theme} onValueChange={handleChange} aria-label="Дэлгэцийн горим">
<SegmentedControlIndicator />
<SegmentedControlItem value="system">
<SegmentedControlItemHiddenInput />
{labels.system}
</SegmentedControlItem>
<SegmentedControlItem value="light">
<SegmentedControlItemHiddenInput />
{labels.light}
</SegmentedControlItem>
<SegmentedControlItem value="dark">
<SegmentedControlItemHiddenInput />
{labels.dark}
</SegmentedControlItem>
</SegmentedControlRoot>
);
}

View file

@ -7,6 +7,9 @@ export type { AmountToggleProps } from "./AmountToggle";
export { HideAmountsToggle, HIDE_AMOUNTS_EVENT } from "./HideAmountsToggle";
export type { HideAmountsToggleProps } from "./HideAmountsToggle";
export { ThemeToggle, getStoredTheme } from "./ThemeToggle";
export type { MercuryTheme, ThemeToggleProps } from "./ThemeToggle";
export { TabBar } from "./TabBar";
export type { TabBarProps, TabKey } from "./TabBar";

View file

@ -3,7 +3,7 @@
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Card, HideAmountsToggle, Icon, IconChip, MercuryButton } from "@/ds";
import { Card, HideAmountsToggle, Icon, IconChip, MercuryButton, ThemeToggle } from "@/ds";
import type { IconName } from "@/ds";
import { useMe, useSettings } from "@/api/hooks/reads";
import { profileStrings } from "./strings";
@ -101,6 +101,19 @@ export function ProfileView() {
</nav>
</Card>
<Card style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{profileStrings.display.themeMode}
</span>
<ThemeToggle
labels={{
system: profileStrings.display.themeSystem,
light: profileStrings.display.themeLight,
dark: profileStrings.display.themeDark,
}}
/>
</Card>
<Card>
<ConnectedBanks />
</Card>

View file

@ -19,6 +19,10 @@ export const profileStrings = {
display: {
title: "Дэлгэц ба нууцлал",
hideAmounts: "Үнийн дүн нуух",
themeMode: "Дэлгэцийн горим",
themeSystem: "Систем",
themeLight: "Гэрэл",
themeDark: "Харанхуй",
},
banks: {
title: "Банкууд",