diff --git a/frontend/src/v2/components/V2ThemeSwitch.tsx b/frontend/src/v2/components/V2ThemeSwitch.tsx
new file mode 100644
index 0000000000..fdb478dab0
--- /dev/null
+++ b/frontend/src/v2/components/V2ThemeSwitch.tsx
@@ -0,0 +1,37 @@
+import React, { useSyncExternalStore } from 'react';
+import { useTranslation } from 'react-i18next';
+import { getV2Theme, resolveV2Theme, setV2Theme, subscribeV2Theme } from '../utils/theme';
+import './v2-theme-switch.css';
+
+const Sun = () => (
+
+);
+const Moon = () => (
+
+);
+
+// D224 / TASK-740: the sun and moon pair from the d215 rail. It writes the same
+// stored preference as Settings > Theme (setV2Theme), so there is one setting.
+// An 'auto' preference shows whichever set the OS is giving; a click makes it
+// explicit.
+// 'rail' is the 1440 sidebar; 'bar' is the 390 Pods top bar (the rail is gone there).
+const V2ThemeSwitch: React.FC<{ variant?: 'rail' | 'bar' }> = ({ variant = 'rail' }) => {
+ const { t } = useTranslation();
+ const active = useSyncExternalStore(
+ subscribeV2Theme,
+ () => resolveV2Theme(getV2Theme()),
+ () => 'light',
+ );
+ return (
+
+
+
+
+ );
+};
+
+export default V2ThemeSwitch;
diff --git a/frontend/src/v2/components/v2-theme-switch.css b/frontend/src/v2/components/v2-theme-switch.css
new file mode 100644
index 0000000000..296056b742
--- /dev/null
+++ b/frontend/src/v2/components/v2-theme-switch.css
@@ -0,0 +1,13 @@
+/* TASK-740 (D224): the d215 rail theme switch, lifted from
+ docs/design/artboards/d215/index.html (.seg, .seg--ic, .seg--rail, lines
+ 169-178). Scoped by .v2-theme-seg so the generic .seg names stay local; --sm is the 390 Pods top bar (.mtop). The
+ artboard's short colour names map to --v2-* as in primitives.css; the on state
+ is aria-pressed (the artboard's .on), the same look. */
+.seg.v2-theme-seg { display: inline-flex; gap: 2px; padding: 2px; border-radius: var(--v2-r-9); background: var(--v2-surface-tint); box-shadow: inset 0 0 0 1px var(--v2-border-soft); flex: none; }
+.seg.v2-theme-seg button { all: unset; box-sizing: border-box; cursor: pointer; height: 28px; padding: 0; width: 32px; border-radius: var(--v2-r-7); display: inline-flex; align-items: center; justify-content: center; gap: 6px; color: var(--v2-text-secondary); white-space: nowrap; }
+.seg.v2-theme-seg button[aria-pressed="true"] { background: var(--v2-surface); color: var(--v2-text-primary); box-shadow: 0 0 0 1px var(--v2-key-edge), 0 1px 2px rgba(16, 24, 40, 0.08); }
+.seg.v2-theme-seg button:focus-visible { box-shadow: var(--v2-focus-ring); outline: 1px solid var(--v2-accent); }
+.seg.v2-theme-seg.seg--ic svg.i { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+.seg.v2-theme-seg.seg--rail { flex-direction: column; margin-bottom: 4px; }
+.seg.v2-theme-seg.seg--rail button { height: 30px; }
+.seg.v2-theme-seg.seg--sm button { width: 28px; height: 24px; }
diff --git a/frontend/src/v2/hooks/useV2ThemePref.ts b/frontend/src/v2/hooks/useV2ThemePref.ts
new file mode 100644
index 0000000000..4d8b7a7fcf
--- /dev/null
+++ b/frontend/src/v2/hooks/useV2ThemePref.ts
@@ -0,0 +1,7 @@
+import { useSyncExternalStore } from 'react';
+import { getV2Theme, subscribeV2Theme, V2ThemePref } from '../utils/theme';
+
+// The stored theme preference, re-read whenever setV2Theme changes it.
+export default function useV2ThemePref(): V2ThemePref {
+ return useSyncExternalStore(subscribeV2Theme, getV2Theme, () => 'auto');
+}
diff --git a/frontend/src/v2/utils/theme.ts b/frontend/src/v2/utils/theme.ts
index 667dfd9c4b..c8ef10429b 100644
--- a/frontend/src/v2/utils/theme.ts
+++ b/frontend/src/v2/utils/theme.ts
@@ -19,6 +19,28 @@ export function applyV2Theme(pref: V2ThemePref): void {
document.documentElement.setAttribute('data-v2-theme', pref);
}
+const listeners = new Set<() => void>();
+
+// Lets every mounted control (the Settings picker, the rail switch) follow the
+// one stored preference instead of keeping a copy of it.
+// It also fires when the OS flips light/dark, so an 'auto' preference stays current.
+export function subscribeV2Theme(listener: () => void): () => void {
+ listeners.add(listener);
+ const mql = typeof window !== 'undefined' ? window.matchMedia?.('(prefers-color-scheme: dark)') : undefined;
+ mql?.addEventListener?.('change', listener);
+ return () => {
+ listeners.delete(listener);
+ mql?.removeEventListener?.('change', listener);
+ };
+}
+
+// 'auto' follows the OS; the rail switch needs the set actually showing.
+export function resolveV2Theme(pref: V2ThemePref): 'light' | 'dark' {
+ if (pref !== 'auto') return pref;
+ return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
+ ? 'dark' : 'light';
+}
+
export function setV2Theme(pref: V2ThemePref): void {
try {
window.localStorage.setItem(V2_THEME_KEY, pref);
@@ -26,4 +48,5 @@ export function setV2Theme(pref: V2ThemePref): void {
// Storage can be blocked; the choice still applies for this page view.
}
applyV2Theme(pref);
+ listeners.forEach((listener) => listener());
}