diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 5a71206..d5a47fa 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,9 +2,31 @@ import "./globals.css";
export const metadata = { title: "Mercury" };
+// Runs before paint (in
, 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 (
-
+ // 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.
+
+
+
+
{children}
);
diff --git a/src/ds/ThemeToggle.test.tsx b/src/ds/ThemeToggle.test.tsx
new file mode 100644
index 0000000..b5bfa03
--- /dev/null
+++ b/src/ds/ThemeToggle.test.tsx
@@ -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();
+ expect(screen.getByText("Систем")).toBeInTheDocument();
+ expect(screen.getByText("Гэрэл")).toBeInTheDocument();
+ expect(screen.getByText("Харанхуй")).toBeInTheDocument();
+
+ cleanup();
+ localStorage.setItem("mercury.theme", "dark");
+ render();
+ 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();
+
+ 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();
+
+ 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");
+});
diff --git a/src/ds/ThemeToggle.tsx b/src/ds/ThemeToggle.tsx
new file mode 100644
index 0000000..10999eb
--- /dev/null
+++ b/src/ds/ThemeToggle.tsx
@@ -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 ``
+ * 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(() => getStoredTheme());
+
+ function handleChange(next: string) {
+ const nextTheme = next as MercuryTheme;
+ setTheme(nextTheme);
+ persistTheme(nextTheme);
+ applyTheme(nextTheme);
+ }
+
+ return (
+
+
+
+
+ {labels.system}
+
+
+
+ {labels.light}
+
+
+
+ {labels.dark}
+
+
+ );
+}
diff --git a/src/ds/index.ts b/src/ds/index.ts
index 7891c3a..230d554 100644
--- a/src/ds/index.ts
+++ b/src/ds/index.ts
@@ -7,7 +7,8 @@ export type { AmountToggleProps } from "./AmountToggle";
export { HideAmountsToggle, HIDE_AMOUNTS_EVENT } from "./HideAmountsToggle";
export type { HideAmountsToggleProps } from "./HideAmountsToggle";
-export { SyncButton } from "./SyncButton";
+export { ThemeToggle, getStoredTheme } from "./ThemeToggle";
+export type { MercuryTheme, ThemeToggleProps } from "./ThemeToggle";
export { TabBar } from "./TabBar";
export type { TabBarProps, TabKey } from "./TabBar";
@@ -22,3 +23,4 @@ export * from "./icons";
export * from "./IconChip";
export * from "./SectionHeader";
export * from "./EmptyState";
+export * from "./SyncButton";
diff --git a/src/features/profile/ProfileView.tsx b/src/features/profile/ProfileView.tsx
index b63bd7d..b777521 100644
--- a/src/features/profile/ProfileView.tsx
+++ b/src/features/profile/ProfileView.tsx
@@ -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() {
+
+
+ {profileStrings.display.themeMode}
+
+
+
+
diff --git a/src/features/profile/strings.ts b/src/features/profile/strings.ts
index 0b7b2ea..7621ff3 100644
--- a/src/features/profile/strings.ts
+++ b/src/features/profile/strings.ts
@@ -19,6 +19,10 @@ export const profileStrings = {
display: {
title: "Дэлгэц ба нууцлал",
hideAmounts: "Үнийн дүн нуух",
+ themeMode: "Дэлгэцийн горим",
+ themeSystem: "Систем",
+ themeLight: "Гэрэл",
+ themeDark: "Харанхуй",
},
banks: {
title: "Банкууд",