Repository navigation
feat(web): add dark mode toggle #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,14 @@ | ||
| import '@testing-library/jest-dom/vitest'; | ||
|
|
||
| if (typeof window.matchMedia !== 'function') { | ||
| window.matchMedia = (query: string) => ({ | ||
| matches: false, | ||
| media: query, | ||
| onchange: null, | ||
| addListener: () => {}, | ||
| removeListener: () => {}, | ||
| addEventListener: () => {}, | ||
| removeEventListener: () => {}, | ||
| dispatchEvent: () => false, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { createContext, useContext, useEffect, useState } from 'react'; | ||
|
|
||
| export type Theme = 'light' | 'dark' | 'system'; | ||
|
|
||
| export const THEME_STORAGE_KEY = 'theme'; | ||
|
|
||
| function isTheme(value: string | null): value is Theme { | ||
| return value === 'light' || value === 'dark' || value === 'system'; | ||
| } | ||
|
|
||
| function getSystemTheme(): 'light' | 'dark' { | ||
| return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | ||
| } | ||
|
|
||
| function applyResolvedTheme(theme: Theme): void { | ||
| const resolved = theme === 'system' ? getSystemTheme() : theme; | ||
| document.documentElement.classList.toggle('dark', resolved === 'dark'); | ||
| } | ||
|
|
||
| // Inlined into the document head so the theme is applied before hydration | ||
| // and before first paint, avoiding a flash of the wrong theme. | ||
| export const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('${THEME_STORAGE_KEY}');var d=t==='dark'||((t==='system'||!t)&&window.matchMedia('(prefers-color-scheme: dark)').matches);if(d)document.documentElement.classList.add('dark');}catch(e){}})();`; | ||
|
|
||
| interface ThemeContextValue { | ||
| theme: Theme; | ||
| setTheme: (theme: Theme) => void; | ||
| } | ||
|
|
||
| const ThemeContext = createContext<ThemeContextValue | null>(null); | ||
|
|
||
| export function ThemeProvider({ children }: { children: React.ReactNode }) { | ||
| const [theme, setThemeState] = useState<Theme>(() => { | ||
| if (typeof window === 'undefined') return 'system'; | ||
| const stored = localStorage.getItem(THEME_STORAGE_KEY); | ||
| return isTheme(stored) ? stored : 'system'; | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| applyResolvedTheme(theme); | ||
| if (theme !== 'system') return; | ||
| const media = window.matchMedia('(prefers-color-scheme: dark)'); | ||
| const onChange = () => applyResolvedTheme('system'); | ||
| media.addEventListener('change', onChange); | ||
| return () => media.removeEventListener('change', onChange); | ||
| }, [theme]); | ||
|
|
||
| const setTheme = (next: Theme) => { | ||
| localStorage.setItem(THEME_STORAGE_KEY, next); | ||
| setThemeState(next); | ||
| }; | ||
|
|
||
| return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>; | ||
| } | ||
|
|
||
| export function useTheme(): ThemeContextValue { | ||
| const ctx = useContext(ThemeContext); | ||
| if (!ctx) throw new Error('useTheme must be used within a ThemeProvider'); | ||
| return ctx; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| @import 'tailwindcss'; | ||
|
|
||
| @custom-variant dark (&:where(.dark, .dark *)); | ||
|
|
||
| * { | ||
| box-sizing: border-box; | ||
| } | ||
|
|
@@ -13,3 +15,11 @@ body, | |
| body { | ||
| margin: 0; | ||
| } | ||
|
|
||
| body, | ||
| body * { | ||
| transition: | ||
| background-color 0.15s ease, | ||
| border-color 0.15s ease, | ||
| color 0.15s ease; | ||
| } | ||
|
Comment on lines
+19
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Respect reduced-motion preferences. This forces colour transitions on every descendant, including for users who request reduced motion. Add a 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Restore the AccountPage test provider.
useTheme()now throws before the existing directAccountPagerenders, causing the reported 31 CI test failures. Wrap the shared account-page test render helper inThemeProvider(or mockuseTheme) so the existing form tests can run.🧰 Tools
🪛 GitHub Actions: CI / 1_Test.txt
[error] 268-268: useTheme called without a ThemeProvider while rendering AccountPage (triggering: "useTheme must be used within a ThemeProvider").
🪛 GitHub Actions: CI / Test
[error] 268-268: useTheme must be used within a ThemeProvider (thrown while rendering AccountPage during tests)
🤖 Prompt for AI Agents
Sources: Linters/SAST tools, Pipeline failures