Skip to content

Commit fbe8e71

Browse files
authored
Merge pull request #1452 from okonkwofreeman001/main
feat(frontend): dark-theme token overrides via data-theme with explicit-choice persistence
2 parents 94702c3 + 699da62 commit fbe8e71

5 files changed

Lines changed: 202 additions & 84 deletions

File tree

frontend/src/components/markets/MarketStatusBadge.css

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
border: 1px solid rgba(52, 211, 153, 0.3);
2727
}
2828

29-
.light-mode .market-status-badge.status-active {
29+
[data-theme='light'] .market-status-badge.status-active {
3030
background-color: rgba(5, 150, 105, 0.1);
3131
color: var(--success);
3232
border: 1px solid rgba(5, 150, 105, 0.25);
@@ -39,7 +39,7 @@
3939
border: 1px solid rgba(245, 158, 11, 0.3);
4040
}
4141

42-
.light-mode .market-status-badge.status-pending {
42+
[data-theme='light'] .market-status-badge.status-pending {
4343
background-color: rgba(245, 158, 11, 0.12);
4444
color: var(--primary);
4545
border: 1px solid rgba(245, 158, 11, 0.25);
@@ -52,7 +52,7 @@
5252
border: 1px solid rgba(139, 92, 246, 0.3);
5353
}
5454

55-
.light-mode .market-status-badge.status-disputed {
55+
[data-theme='light'] .market-status-badge.status-disputed {
5656
background-color: rgba(139, 92, 246, 0.12);
5757
color: var(--accent);
5858
border: 1px solid rgba(139, 92, 246, 0.25);
@@ -65,7 +65,7 @@
6565
border: 1px solid rgba(159, 176, 204, 0.3);
6666
}
6767

68-
.light-mode .market-status-badge.status-resolved {
68+
[data-theme='light'] .market-status-badge.status-resolved {
6969
background-color: rgba(74, 88, 120, 0.1);
7070
color: var(--fg-muted);
7171
border: 1px solid rgba(74, 88, 120, 0.2);
@@ -78,7 +78,7 @@
7878
border: 1px solid rgba(248, 113, 113, 0.3);
7979
}
8080

81-
.light-mode .market-status-badge.status-cancelled {
81+
[data-theme='light'] .market-status-badge.status-cancelled {
8282
background-color: rgba(220, 38, 38, 0.1);
8383
color: var(--destructive);
8484
border: 1px solid rgba(220, 38, 38, 0.25);
@@ -91,7 +91,7 @@
9191
border: 1px solid rgba(107, 124, 156, 0.3);
9292
}
9393

94-
.light-mode .market-status-badge.status-unknown {
94+
[data-theme='light'] .market-status-badge.status-unknown {
9595
background-color: rgba(107, 124, 156, 0.1);
9696
color: var(--fg-subtle);
9797
border: 1px solid rgba(107, 124, 156, 0.2);

frontend/src/lib/darkMode.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const DARK_MODE_STORAGE_KEY = 'darkMode';
2+
export const THEME_ATTRIBUTE = 'data-theme';
23

34
export interface DarkModePreference {
45
isDarkMode: boolean;
@@ -32,6 +33,16 @@ export function getDarkModePreference(): DarkModePreference {
3233
};
3334
}
3435

36+
/**
37+
* Apply a preference to <html>.
38+
*
39+
* - Explicit user choice → set data-theme="dark" | "light". The attribute
40+
* outranks the prefers-color-scheme media query in tokens.css (which only
41+
* matches :root:not([data-theme])), so an OS-level theme change can never
42+
* override a stored choice.
43+
* - Following the system → remove the attribute. The CSS media query then
44+
* tracks prefers-color-scheme live, with no page reload required.
45+
*/
3546
export function applyDarkModePreference({
3647
isDarkMode,
3748
hasStoredPreference,
@@ -40,24 +51,27 @@ export function applyDarkModePreference({
4051
return;
4152
}
4253

43-
document.documentElement.classList.toggle('dark-mode', isDarkMode);
44-
document.documentElement.classList.toggle(
45-
'light-mode',
46-
!isDarkMode && hasStoredPreference,
47-
);
54+
if (hasStoredPreference) {
55+
document.documentElement.setAttribute(
56+
THEME_ATTRIBUTE,
57+
isDarkMode ? 'dark' : 'light',
58+
);
59+
} else {
60+
document.documentElement.removeAttribute(THEME_ATTRIBUTE);
61+
}
4862
}
4963

5064
export const darkModeInitScript = `
5165
(function () {
5266
try {
5367
var stored = localStorage.getItem('${DARK_MODE_STORAGE_KEY}');
5468
var hasStoredPreference = stored === 'true' || stored === 'false';
55-
var isDarkMode = hasStoredPreference
56-
? stored === 'true'
57-
: window.matchMedia('(prefers-color-scheme: dark)').matches;
58-
59-
document.documentElement.classList.toggle('dark-mode', isDarkMode);
60-
document.documentElement.classList.toggle('light-mode', !isDarkMode && hasStoredPreference);
69+
// Only an explicit choice pins the data-theme attribute. Without one the
70+
// CSS prefers-color-scheme media query drives the theme (and keeps
71+
// following OS changes live), so nothing is written here.
72+
if (hasStoredPreference) {
73+
document.documentElement.setAttribute('${THEME_ATTRIBUTE}', stored === 'true' ? 'dark' : 'light');
74+
}
6175
} catch (error) {}
6276
})();
6377
`;

frontend/src/lib/hooks/__tests__/useDarkMode.test.ts

Lines changed: 94 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { darkModeInitScript } from '../../darkMode';
33
import { useDarkMode } from '../useDarkMode';
44

55
describe('useDarkMode', () => {
6+
const changeHandlers: Array<(event: { matches: boolean }) => void> = [];
7+
68
const mockMatchMedia = (matches: boolean) => {
79
Object.defineProperty(window, 'matchMedia', {
810
writable: true,
@@ -12,20 +14,26 @@ describe('useDarkMode', () => {
1214
onchange: null,
1315
addListener: jest.fn(),
1416
removeListener: jest.fn(),
15-
addEventListener: jest.fn(),
17+
addEventListener: jest.fn(
18+
(_type: string, handler: (event: { matches: boolean }) => void) => {
19+
changeHandlers.push(handler);
20+
},
21+
),
1622
removeEventListener: jest.fn(),
1723
dispatchEvent: jest.fn(),
1824
})),
1925
});
2026
};
2127

2228
beforeEach(() => {
29+
changeHandlers.length = 0;
2330
localStorage.clear();
24-
document.documentElement.classList.remove('dark-mode');
25-
document.documentElement.classList.remove('light-mode');
31+
document.documentElement.removeAttribute('data-theme');
2632
mockMatchMedia(false);
2733
});
2834

35+
const getTheme = () => document.documentElement.getAttribute('data-theme');
36+
2937
it('should initialize with light mode by default', async () => {
3038
const { result } = renderHook(() => useDarkMode());
3139

@@ -35,53 +43,52 @@ describe('useDarkMode', () => {
3543
expect(localStorage.getItem('darkMode')).toBeNull();
3644
});
3745

38-
it('should use system dark mode when no preference is stored', async () => {
46+
it('should follow system dark mode when no preference is stored, without pinning the theme', async () => {
3947
mockMatchMedia(true);
4048

4149
const { result } = renderHook(() => useDarkMode());
4250

4351
await waitFor(() => expect(result.current.isLoaded).toBe(true));
4452

4553
expect(result.current.isDarkMode).toBe(true);
46-
expect(document.documentElement.classList.contains('dark-mode')).toBe(true);
47-
expect(document.documentElement.classList.contains('light-mode')).toBe(false);
54+
// Following the system must NOT look like an explicit choice.
55+
expect(getTheme()).toBeNull();
4856
expect(localStorage.getItem('darkMode')).toBeNull();
4957
});
5058

5159
it('should toggle dark mode', async () => {
5260
const { result } = renderHook(() => useDarkMode());
5361

5462
await waitFor(() => expect(result.current.isLoaded).toBe(true));
55-
63+
5664
act(() => {
5765
result.current.toggleDarkMode();
5866
});
59-
67+
6068
expect(result.current.isDarkMode).toBe(true);
6169
});
6270

6371
it('should persist dark mode preference to localStorage', async () => {
6472
const { result } = renderHook(() => useDarkMode());
6573

6674
await waitFor(() => expect(result.current.isLoaded).toBe(true));
67-
75+
6876
act(() => {
6977
result.current.toggleDarkMode();
7078
});
71-
79+
7280
expect(localStorage.getItem('darkMode')).toBe('true');
7381
});
7482

7583
it('should load dark mode preference from localStorage', async () => {
7684
localStorage.setItem('darkMode', 'true');
77-
85+
7886
const { result } = renderHook(() => useDarkMode());
79-
87+
8088
await waitFor(() => expect(result.current.isLoaded).toBe(true));
8189

8290
expect(result.current.isDarkMode).toBe(true);
83-
expect(document.documentElement.classList.contains('dark-mode')).toBe(true);
84-
expect(document.documentElement.classList.contains('light-mode')).toBe(false);
91+
expect(getTheme()).toBe('dark');
8592
});
8693

8794
it('should restore stored light preference over system dark mode', async () => {
@@ -93,17 +100,15 @@ describe('useDarkMode', () => {
93100
await waitFor(() => expect(result.current.isLoaded).toBe(true));
94101

95102
expect(result.current.isDarkMode).toBe(false);
96-
expect(document.documentElement.classList.contains('dark-mode')).toBe(false);
97-
expect(document.documentElement.classList.contains('light-mode')).toBe(true);
103+
expect(getTheme()).toBe('light');
98104
});
99105

100106
it('should apply stored dark preference before React loads', () => {
101107
localStorage.setItem('darkMode', 'true');
102108

103109
Function(darkModeInitScript)();
104110

105-
expect(document.documentElement.classList.contains('dark-mode')).toBe(true);
106-
expect(document.documentElement.classList.contains('light-mode')).toBe(false);
111+
expect(getTheme()).toBe('dark');
107112
});
108113

109114
it('should apply stored light preference before React loads', () => {
@@ -112,35 +117,44 @@ describe('useDarkMode', () => {
112117

113118
Function(darkModeInitScript)();
114119

115-
expect(document.documentElement.classList.contains('dark-mode')).toBe(false);
116-
expect(document.documentElement.classList.contains('light-mode')).toBe(true);
120+
expect(getTheme()).toBe('light');
121+
});
122+
123+
it('should not pin the theme before React loads when no preference is stored', () => {
124+
mockMatchMedia(true);
125+
126+
Function(darkModeInitScript)();
127+
128+
// Following the system: the CSS prefers-color-scheme media query drives
129+
// the theme, so the init script must leave the attribute untouched.
130+
expect(getTheme()).toBeNull();
117131
});
118132

119133
// ------------------------------------------------------------------
120-
// Bug #1159 — lazy initializer reads DOM class on first render
134+
// Bug #1159 — lazy initializer reads DOM state on first render
121135
// ------------------------------------------------------------------
122136

123-
it('reads dark-mode class from DOM and returns isDarkMode:true on the very first render (before isLoaded)', () => {
137+
it('reads data-theme="dark" from DOM and returns isDarkMode:true on the very first render (before isLoaded)', () => {
124138
// Simulate what the inline init script does before React hydrates.
125-
document.documentElement.classList.add('dark-mode');
139+
document.documentElement.setAttribute('data-theme', 'dark');
126140

127141
const { result } = renderHook(() => useDarkMode());
128142

129143
// isDarkMode must be true on the FIRST render, not only after useEffect.
130144
expect(result.current.isDarkMode).toBe(true);
131145
});
132146

133-
it('reads light-mode class from DOM and returns isDarkMode:false on the very first render', () => {
134-
document.documentElement.classList.add('light-mode');
147+
it('reads data-theme="light" from DOM and returns isDarkMode:false on the very first render', () => {
148+
document.documentElement.setAttribute('data-theme', 'light');
135149

136150
const { result } = renderHook(() => useDarkMode());
137151

138152
expect(result.current.isDarkMode).toBe(false);
139153
});
140154

141155
it('no stale icon flash: isDarkMode is already correct before isLoaded becomes true', async () => {
142-
// Init script applied dark-mode before React renders.
143-
document.documentElement.classList.add('dark-mode');
156+
// Init script applied data-theme="dark" before React renders.
157+
document.documentElement.setAttribute('data-theme', 'dark');
144158

145159
const { result } = renderHook(() => useDarkMode());
146160

@@ -152,8 +166,8 @@ describe('useDarkMode', () => {
152166
expect(result.current.isDarkMode).toBe(true);
153167
});
154168

155-
it('falls back to getDarkModePreference when no DOM class is set', async () => {
156-
// Neither class present — should fall back to system/storage preference.
169+
it('falls back to getDarkModePreference when no data-theme attribute is set', async () => {
170+
// No attribute present — should fall back to system/storage preference.
157171
mockMatchMedia(false);
158172

159173
const { result } = renderHook(() => useDarkMode());
@@ -162,8 +176,8 @@ describe('useDarkMode', () => {
162176
expect(result.current.isDarkMode).toBe(false);
163177
});
164178

165-
it('toggleDarkMode still works correctly after lazy-init from DOM class', async () => {
166-
document.documentElement.classList.add('dark-mode');
179+
it('toggleDarkMode still works correctly after lazy-init from DOM attribute', async () => {
180+
document.documentElement.setAttribute('data-theme', 'dark');
167181

168182
const { result } = renderHook(() => useDarkMode());
169183

@@ -174,35 +188,73 @@ describe('useDarkMode', () => {
174188
});
175189

176190
expect(result.current.isDarkMode).toBe(false);
177-
expect(document.documentElement.classList.contains('dark-mode')).toBe(false);
191+
expect(getTheme()).toBe('light');
178192
expect(localStorage.getItem('darkMode')).toBe('false');
179193
});
180194

181-
it('should add dark-mode class to document element', async () => {
195+
it('should set data-theme="dark" when toggling on', async () => {
182196
const { result } = renderHook(() => useDarkMode());
183197

184198
await waitFor(() => expect(result.current.isLoaded).toBe(true));
185-
199+
186200
act(() => {
187201
result.current.toggleDarkMode();
188202
});
189-
190-
expect(document.documentElement.classList.contains('dark-mode')).toBe(true);
203+
204+
expect(getTheme()).toBe('dark');
191205
});
192206

193-
it('should remove dark-mode class when toggling off', async () => {
207+
it('should set data-theme="light" when toggling off', async () => {
194208
localStorage.setItem('darkMode', 'true');
195-
209+
196210
const { result } = renderHook(() => useDarkMode());
197211

198212
await waitFor(() => expect(result.current.isLoaded).toBe(true));
199-
213+
200214
act(() => {
201215
result.current.toggleDarkMode();
202216
});
203-
204-
expect(document.documentElement.classList.contains('dark-mode')).toBe(false);
205-
expect(document.documentElement.classList.contains('light-mode')).toBe(true);
217+
218+
expect(result.current.isDarkMode).toBe(false);
219+
expect(getTheme()).toBe('light');
206220
expect(localStorage.getItem('darkMode')).toBe('false');
207221
});
222+
223+
// ------------------------------------------------------------------
224+
// System-following vs explicit choice
225+
// ------------------------------------------------------------------
226+
227+
it('follows OS theme changes live when no explicit choice is stored', async () => {
228+
mockMatchMedia(false);
229+
230+
const { result } = renderHook(() => useDarkMode());
231+
232+
await waitFor(() => expect(result.current.isLoaded).toBe(true));
233+
expect(result.current.isDarkMode).toBe(false);
234+
235+
// OS flips to dark — the hook reacts without a page reload.
236+
act(() => {
237+
changeHandlers.forEach((handler) => handler({ matches: true }));
238+
});
239+
240+
expect(result.current.isDarkMode).toBe(true);
241+
// Still following the system: no explicit theme is pinned.
242+
expect(getTheme()).toBeNull();
243+
expect(localStorage.getItem('darkMode')).toBeNull();
244+
});
245+
246+
it('does not register an OS-change listener when an explicit choice is stored', async () => {
247+
mockMatchMedia(true); // OS prefers dark
248+
localStorage.setItem('darkMode', 'false'); // but the user chose light
249+
250+
const { result } = renderHook(() => useDarkMode());
251+
252+
await waitFor(() => expect(result.current.isLoaded).toBe(true));
253+
254+
expect(result.current.isDarkMode).toBe(false);
255+
expect(getTheme()).toBe('light');
256+
257+
// The stored choice must never be overridden by an OS-level change.
258+
expect(changeHandlers).toHaveLength(0);
259+
});
208260
});

0 commit comments

Comments
 (0)