Skip to content

Commit 5e09ebc

Browse files
committed
fix(app): add 3-way theme preference with system-follow, bootstrap, and accessibility
- Refactor theme state to support light/dark/system preference model - Add pre-paint bootstrap script in index.html to prevent first-load flash - Persist preference (not resolved theme) to avoid overwriting system choice - Add live OS theme change listener for system-follow behavior - Sync meta theme-color on theme changes - Suppress transitions during theme apply to reduce visual flash - Replace custom radio with accessible RadioGroup + RadioGroupItem - Expand tests for preference persistence and theme-color updates - Add sync comments between bootstrap and runtime logic
1 parent 3db29cd commit 5e09ebc

File tree

4 files changed

+355
-144
lines changed

4 files changed

+355
-144
lines changed

apps/app/index.html

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,57 @@
1414
<meta property="og:url" content="" />
1515
<meta property="og:image" content="" />
1616
<meta name="theme-color" content="#fafafa" />
17+
<script>
18+
(() => {
19+
// Keep this bootstrap logic in sync with runtime theme resolution in lib/theme.tsx.
20+
const STORAGE_KEY = "app-theme-preference";
21+
const LEGACY_STORAGE_KEY = "app-theme";
22+
const LIGHT_THEME_COLOR = "#fafafa";
23+
const DARK_THEME_COLOR = "#0f0f0f";
24+
25+
function parsePreference(value) {
26+
if (value === "light" || value === "dark" || value === "system") {
27+
return value;
28+
}
29+
return null;
30+
}
31+
32+
function getSystemTheme() {
33+
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
34+
? "dark"
35+
: "light";
36+
}
37+
38+
let preference = "system";
39+
40+
try {
41+
const stored = parsePreference(
42+
window.localStorage.getItem(STORAGE_KEY),
43+
);
44+
if (stored) {
45+
preference = stored;
46+
} else {
47+
const legacy = parsePreference(
48+
window.localStorage.getItem(LEGACY_STORAGE_KEY),
49+
);
50+
if (legacy === "light" || legacy === "dark") {
51+
preference = legacy;
52+
}
53+
}
54+
} catch {
55+
preference = "system";
56+
}
57+
58+
const theme = preference === "system" ? getSystemTheme() : preference;
59+
document.documentElement.classList.toggle("dark", theme === "dark");
60+
61+
const meta = document.querySelector('meta[name="theme-color"]');
62+
meta?.setAttribute(
63+
"content",
64+
theme === "dark" ? DARK_THEME_COLOR : LIGHT_THEME_COLOR,
65+
);
66+
})();
67+
</script>
1768

1869
<link rel="icon" href="/favicon.ico" sizes="any" />
1970
<link rel="apple-touch-icon" href="/logo192.png" />

apps/app/lib/theme.test.tsx

Lines changed: 123 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
99
import { ThemeProvider, useTheme } from "./theme";
1010

1111
function ThemeProbe() {
12-
const { theme, setTheme, toggleTheme } = useTheme();
12+
const { theme, preference, setPreference } = useTheme();
1313

1414
return (
1515
<>
16-
<div data-testid="theme-value">{theme}</div>
17-
<button onClick={() => setTheme("dark")} type="button">
18-
set-dark
19-
</button>
20-
<button onClick={() => setTheme("light")} type="button">
16+
<div data-testid="resolved-theme">{theme}</div>
17+
<div data-testid="preference">{preference}</div>
18+
<button onClick={() => setPreference("light")} type="button">
2119
set-light
2220
</button>
23-
<button onClick={toggleTheme} type="button">
24-
toggle-theme
21+
<button onClick={() => setPreference("dark")} type="button">
22+
set-dark
23+
</button>
24+
<button onClick={() => setPreference("system")} type="button">
25+
set-system
2526
</button>
2627
</>
2728
);
@@ -32,6 +33,19 @@ describe("ThemeProvider", () => {
3233
let originalMatchMedia: typeof window.matchMedia | undefined;
3334
let originalLocalStorage: Storage;
3435

36+
function createMediaQueryList(matches: boolean): MediaQueryList {
37+
return {
38+
matches,
39+
media: "(prefers-color-scheme: dark)",
40+
onchange: null,
41+
addEventListener: vi.fn(),
42+
removeEventListener: vi.fn(),
43+
addListener: vi.fn(),
44+
removeListener: vi.fn(),
45+
dispatchEvent: vi.fn(),
46+
} as unknown as MediaQueryList;
47+
}
48+
3549
const localStorageMock: Storage = {
3650
getItem: (key: string) => storage.get(key) ?? null,
3751
setItem: (key: string, value: string) => {
@@ -56,13 +70,30 @@ describe("ThemeProvider", () => {
5670
originalMatchMedia = window.matchMedia;
5771
originalLocalStorage = window.localStorage;
5872

73+
Object.defineProperty(window, "matchMedia", {
74+
configurable: true,
75+
writable: true,
76+
value: vi.fn().mockReturnValue(createMediaQueryList(false)),
77+
});
78+
5979
Object.defineProperty(window, "localStorage", {
6080
configurable: true,
6181
writable: true,
6282
value: localStorageMock,
6383
});
84+
6485
window.localStorage.clear();
6586
document.documentElement.classList.remove("dark");
87+
88+
const existingMeta = document.querySelector('meta[name="theme-color"]');
89+
if (!existingMeta) {
90+
const meta = document.createElement("meta");
91+
meta.setAttribute("name", "theme-color");
92+
meta.setAttribute("content", "#fafafa");
93+
document.head.appendChild(meta);
94+
} else {
95+
existingMeta.setAttribute("content", "#fafafa");
96+
}
6697
});
6798

6899
afterEach(() => {
@@ -85,89 +116,103 @@ describe("ThemeProvider", () => {
85116
vi.restoreAllMocks();
86117
});
87118

88-
it("uses persisted localStorage theme on first render", () => {
89-
window.localStorage.setItem("app-theme", "dark");
119+
it("defaults to system preference when nothing is stored", () => {
120+
Object.defineProperty(window, "matchMedia", {
121+
configurable: true,
122+
writable: true,
123+
value: vi.fn().mockReturnValue(createMediaQueryList(true)),
124+
});
90125

91126
render(
92127
<ThemeProvider>
93128
<ThemeProbe />
94129
</ThemeProvider>,
95130
);
96131

97-
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
98-
expect(document.documentElement.classList.contains("dark")).toBe(true);
132+
expect(screen.getByTestId("preference")).toHaveTextContent("system");
133+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark");
134+
expect(window.localStorage.getItem("app-theme-preference")).toBe("system");
99135
});
100136

101-
it("falls back to system preference when no theme is persisted", () => {
102-
Object.defineProperty(window, "matchMedia", {
103-
configurable: true,
104-
writable: true,
105-
value: vi.fn().mockImplementation((query: string) => ({
106-
matches: query === "(prefers-color-scheme: dark)",
107-
media: query,
108-
onchange: null,
109-
addEventListener: vi.fn(),
110-
removeEventListener: vi.fn(),
111-
dispatchEvent: vi.fn(),
112-
})),
113-
});
137+
it("uses persisted explicit preference", () => {
138+
window.localStorage.setItem("app-theme-preference", "dark");
114139

115140
render(
116141
<ThemeProvider>
117142
<ThemeProbe />
118143
</ThemeProvider>,
119144
);
120145

121-
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
146+
expect(screen.getByTestId("preference")).toHaveTextContent("dark");
147+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark");
122148
expect(document.documentElement.classList.contains("dark")).toBe(true);
149+
expect(
150+
document
151+
.querySelector('meta[name="theme-color"]')
152+
?.getAttribute("content"),
153+
).toBe("#0f0f0f");
123154
});
124155

125-
it("writes updates to localStorage and keeps DOM class in sync", () => {
156+
it("migrates legacy app-theme values", () => {
157+
window.localStorage.setItem("app-theme", "light");
158+
126159
render(
127160
<ThemeProvider>
128161
<ThemeProbe />
129162
</ThemeProvider>,
130163
);
131164

132-
fireEvent.click(screen.getByRole("button", { name: "set-dark" }));
133-
expect(window.localStorage.getItem("app-theme")).toBe("dark");
134-
expect(document.documentElement.classList.contains("dark")).toBe(true);
135-
136-
fireEvent.click(screen.getByRole("button", { name: "set-light" }));
137-
expect(window.localStorage.getItem("app-theme")).toBe("light");
138-
expect(document.documentElement.classList.contains("dark")).toBe(false);
165+
expect(screen.getByTestId("preference")).toHaveTextContent("light");
166+
expect(window.localStorage.getItem("app-theme-preference")).toBe("light");
139167
});
140168

141-
it("reacts to theme updates from storage events", async () => {
169+
it("updates preference and stores only the preference", () => {
142170
render(
143171
<ThemeProvider>
144172
<ThemeProbe />
145173
</ThemeProvider>,
146174
);
147175

148-
const event = new Event("storage");
149-
Object.defineProperty(event, "key", { value: "app-theme" });
150-
Object.defineProperty(event, "newValue", { value: "dark" });
151-
window.dispatchEvent(event);
176+
fireEvent.click(screen.getByRole("button", { name: "set-dark" }));
177+
expect(screen.getByTestId("preference")).toHaveTextContent("dark");
178+
expect(window.localStorage.getItem("app-theme-preference")).toBe("dark");
179+
expect(
180+
document
181+
.querySelector('meta[name="theme-color"]')
182+
?.getAttribute("content"),
183+
).toBe("#0f0f0f");
152184

153-
await waitFor(() => {
154-
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
155-
expect(document.documentElement.classList.contains("dark")).toBe(true);
156-
});
185+
fireEvent.click(screen.getByRole("button", { name: "set-light" }));
186+
expect(screen.getByTestId("preference")).toHaveTextContent("light");
187+
expect(window.localStorage.getItem("app-theme-preference")).toBe("light");
188+
expect(
189+
document
190+
.querySelector('meta[name="theme-color"]')
191+
?.getAttribute("content"),
192+
).toBe("#fafafa");
193+
194+
fireEvent.click(screen.getByRole("button", { name: "set-system" }));
195+
expect(screen.getByTestId("preference")).toHaveTextContent("system");
196+
expect(window.localStorage.getItem("app-theme-preference")).toBe("system");
157197
});
158198

159-
it("falls back to system preference when storage key is cleared", async () => {
199+
it("reacts to OS theme changes while preference is system", async () => {
200+
const listeners = new Set<(event: MediaQueryListEvent) => void>();
201+
160202
Object.defineProperty(window, "matchMedia", {
161203
configurable: true,
162204
writable: true,
163-
value: vi.fn().mockImplementation((query: string) => ({
164-
matches: query === "(prefers-color-scheme: dark)",
165-
media: query,
166-
onchange: null,
167-
addEventListener: vi.fn(),
168-
removeEventListener: vi.fn(),
169-
dispatchEvent: vi.fn(),
170-
})),
205+
value: vi.fn().mockReturnValue({
206+
...createMediaQueryList(false),
207+
addEventListener: (
208+
_: string,
209+
cb: (event: MediaQueryListEvent) => void,
210+
) => listeners.add(cb),
211+
removeEventListener: (
212+
_: string,
213+
cb: (event: MediaQueryListEvent) => void,
214+
) => listeners.delete(cb),
215+
}),
171216
});
172217

173218
render(
@@ -176,65 +221,46 @@ describe("ThemeProvider", () => {
176221
</ThemeProvider>,
177222
);
178223

179-
fireEvent.click(screen.getByRole("button", { name: "set-light" }));
180-
window.localStorage.removeItem("app-theme");
224+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("light");
181225

182-
const event = new Event("storage");
183-
Object.defineProperty(event, "key", { value: "app-theme" });
184-
Object.defineProperty(event, "newValue", { value: null });
185-
window.dispatchEvent(event);
226+
listeners.forEach((listener) =>
227+
listener({ matches: true } as MediaQueryListEvent),
228+
);
186229

187230
await waitFor(() => {
188-
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
231+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark");
232+
expect(document.documentElement.classList.contains("dark")).toBe(true);
233+
expect(
234+
document
235+
.querySelector('meta[name="theme-color"]')
236+
?.getAttribute("content"),
237+
).toBe("#0f0f0f");
189238
});
190239
});
191240

192-
it("recovers when storage read throws", () => {
193-
Object.defineProperty(window, "localStorage", {
194-
configurable: true,
195-
writable: true,
196-
value: {
197-
...localStorageMock,
198-
getItem: () => {
199-
throw new Error("read denied");
200-
},
201-
} as Storage,
202-
});
203-
204-
Object.defineProperty(window, "matchMedia", {
205-
configurable: true,
206-
writable: true,
207-
value: vi.fn().mockImplementation((query: string) => ({
208-
matches: query === "(prefers-color-scheme: dark)",
209-
media: query,
210-
onchange: null,
211-
addEventListener: vi.fn(),
212-
removeEventListener: vi.fn(),
213-
dispatchEvent: vi.fn(),
214-
})),
215-
});
216-
241+
it("syncs preference across tabs via storage events", async () => {
217242
render(
218243
<ThemeProvider>
219244
<ThemeProbe />
220245
</ThemeProvider>,
221246
);
222247

223-
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
224-
});
248+
const event = new Event("storage");
249+
Object.defineProperty(event, "key", { value: "app-theme-preference" });
250+
Object.defineProperty(event, "newValue", { value: "dark" });
251+
window.dispatchEvent(event);
225252

226-
it("ignores storage write failures", () => {
227-
const setItem = vi.fn(() => {
228-
throw new Error("write denied");
253+
await waitFor(() => {
254+
expect(screen.getByTestId("preference")).toHaveTextContent("dark");
255+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark");
229256
});
257+
});
230258

231-
Object.defineProperty(window, "localStorage", {
259+
it("does not overwrite preference with resolved system theme", () => {
260+
Object.defineProperty(window, "matchMedia", {
232261
configurable: true,
233262
writable: true,
234-
value: {
235-
...localStorageMock,
236-
setItem,
237-
} as Storage,
263+
value: vi.fn().mockReturnValue(createMediaQueryList(true)),
238264
});
239265

240266
render(
@@ -243,9 +269,8 @@ describe("ThemeProvider", () => {
243269
</ThemeProvider>,
244270
);
245271

246-
fireEvent.click(screen.getByRole("button", { name: "set-dark" }));
247-
expect(screen.getByTestId("theme-value")).toHaveTextContent("dark");
248-
expect(document.documentElement.classList.contains("dark")).toBe(true);
249-
expect(setItem).toHaveBeenCalled();
272+
expect(screen.getByTestId("preference")).toHaveTextContent("system");
273+
expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark");
274+
expect(window.localStorage.getItem("app-theme-preference")).toBe("system");
250275
});
251276
});

0 commit comments

Comments
 (0)