Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions __tests__/contexts/theme-context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { act, render, renderHook } from "@testing-library/react";
import { ThemeProvider, useTheme } from "@/contexts/theme-context";

const STORAGE_KEY = "vwc-theme";
const DARK_QUERY = "(prefers-color-scheme: dark)";

type MediaListener = (event: MediaQueryListEvent) => void;

interface MockMediaQueryList {
matches: boolean;
media: string;
addEventListener: (type: "change", listener: MediaListener) => void;
removeEventListener: (type: "change", listener: MediaListener) => void;
dispatchOSChange: (matches: boolean) => void;
}

function installMatchMediaMock(initialMatches: boolean): MockMediaQueryList {
const listeners = new Set<MediaListener>();

const mql: MockMediaQueryList = {
matches: initialMatches,
media: DARK_QUERY,
addEventListener: (_type, listener) => {
listeners.add(listener);
},
removeEventListener: (_type, listener) => {
listeners.delete(listener);
},
dispatchOSChange: (matches: boolean) => {
mql.matches = matches;
for (const listener of listeners) {
listener({ matches } as MediaQueryListEvent);
}
},
};

window.matchMedia = ((query: string) => {
if (query !== DARK_QUERY) {
return {
matches: false,
media: query,
addEventListener: () => undefined,
removeEventListener: () => undefined,
} as unknown as MediaQueryList;
}
return mql as unknown as MediaQueryList;
}) as typeof window.matchMedia;

return mql;
}

beforeEach(() => {
window.localStorage.clear();
document.documentElement.classList.remove("dark");
document.documentElement.style.colorScheme = "";
});

describe("ThemeProvider + useTheme", () => {
it("defaults to system mode and resolves to OS preference", () => {
installMatchMediaMock(true); // OS prefers dark

const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
});

expect(result.current.theme).toBe("system");
expect(result.current.resolvedTheme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});

it("reads stored preference on mount", () => {
installMatchMediaMock(false);
window.localStorage.setItem(STORAGE_KEY, "dark");

const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
});

expect(result.current.theme).toBe("dark");
expect(result.current.resolvedTheme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});

it("setTheme persists to localStorage and updates the html class", () => {
installMatchMediaMock(false);

const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
});

act(() => result.current.setTheme("dark"));

expect(result.current.theme).toBe("dark");
expect(result.current.resolvedTheme).toBe("dark");
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);

act(() => result.current.setTheme("light"));

expect(result.current.resolvedTheme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});

it("system mode flips live when OS theme changes", () => {
const mql = installMatchMediaMock(false);

const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
});

expect(result.current.resolvedTheme).toBe("light");

act(() => mql.dispatchOSChange(true));

expect(result.current.resolvedTheme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});

it("setTheme to explicit value stops live OS sync", () => {
const mql = installMatchMediaMock(false);

const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
});

act(() => result.current.setTheme("light"));
act(() => mql.dispatchOSChange(true));

expect(result.current.theme).toBe("light");
expect(result.current.resolvedTheme).toBe("light");
});

it("throws when useTheme is called outside the provider", () => {
const Consumer = () => {
useTheme();
return null;
};
const original = console.error;
console.error = () => undefined; // silence React's error boundary log
try {
expect(() => render(<Consumer />)).toThrow(
/useTheme must be used within a <ThemeProvider>/
);
} finally {
console.error = original;
}
});
});
153 changes: 153 additions & 0 deletions src/contexts/theme-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";

export type Theme = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";

interface ThemeContextValue {
theme: Theme;
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void;
}

const STORAGE_KEY = "vwc-theme";
const DARK_QUERY = "(prefers-color-scheme: dark)";

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

/**
* Returns the OS-level preference. Safe to call on the server (returns
* `"light"` since `window` isn't available — the pre-paint inline script
* in _document.tsx resolves the *real* value before first paint, so this
* SSR default never reaches the screen).
*/
function getSystemPreference(): ResolvedTheme {
if (typeof window === "undefined") return "light";
return window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
}

function readStoredTheme(): Theme {
if (typeof window === "undefined") return "system";
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
} catch {
// Storage can throw under privacy modes / disabled cookies — fall through.
}
return "system";
}

function applyResolved(resolved: ResolvedTheme): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (resolved === "dark") root.classList.add("dark");
else root.classList.remove("dark");
// For native UI like form controls + scrollbars.
root.style.colorScheme = resolved;
}

interface ThemeProviderProps {
children: ReactNode;
/** Default theme when nothing has been stored yet. Defaults to `"system"`. */
defaultTheme?: Theme;
}

export function ThemeProvider({ children, defaultTheme = "system" }: ThemeProviderProps) {
// SSR-safe initial values — the inline pre-paint script in _document.tsx
// applies the *real* resolved theme to <html> before React hydrates, so
// we don't need to compute it during render and risk a hydration mismatch.
const [theme, setThemeState] = useState<Theme>(defaultTheme);
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>("light");

// Read stored preference on mount and reconcile with what the inline
// script already applied.
useEffect(() => {
const stored = readStoredTheme();
setThemeState(stored);
Comment on lines +71 to +73
}, []);

// Resolve `theme` → `resolvedTheme`, write to <html>, and subscribe to
// OS theme changes when in "system" mode.
useEffect(() => {
if (typeof window === "undefined") return undefined;

const apply = () => {
const next = theme === "system" ? getSystemPreference() : theme;
setResolvedTheme(next);
applyResolved(next);
};

apply();

if (theme !== "system") return undefined;

const mql = window.matchMedia(DARK_QUERY);
const onChange = () => apply();
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [theme]);

const setTheme = useCallback((next: Theme) => {
setThemeState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Same reasoning as readStoredTheme — fall through silently.
}
}, []);

const value = useMemo(
() => ({ theme, resolvedTheme, setTheme }),
[theme, resolvedTheme, setTheme]
);

return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

/**
* Read and update the active theme.
*
* const { theme, resolvedTheme, setTheme } = useTheme();
*
* - `theme` is the user's stored preference (may be `"system"`).
* - `resolvedTheme` is what's currently applied (`"light"` or `"dark"`).
*/
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within a <ThemeProvider>");
}
return ctx;
}

/**
* IIFE injected into _document.tsx so the resolved theme is applied to
* <html> before first paint. Without this, a dark-mode user sees a flash
* of light content while React hydrates. Kept as a string so it can be
* fed to `dangerouslySetInnerHTML`.
*
* The script is intentionally compact and side-effect-only — no
* dependencies, no React, runs before anything else.
*/
export const THEME_PREPAINT_SCRIPT = `
(function () {
try {
var stored = localStorage.getItem('${STORAGE_KEY}');
var theme = (stored === 'light' || stored === 'dark' || stored === 'system') ? stored : 'system';
var resolved = theme === 'system'
? (window.matchMedia('${DARK_QUERY}').matches ? 'dark' : 'light')
: theme;
var root = document.documentElement;
if (resolved === 'dark') root.classList.add('dark');
else root.classList.remove('dark');
root.style.colorScheme = resolved;
} catch (e) {}
})();
`;
33 changes: 30 additions & 3 deletions src/pages/_document.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Document, { Head, Html, Main, NextScript } from "next/document";
import { THEME_PREPAINT_SCRIPT } from "@/contexts/theme-context";

export default class MyDocument extends Document {
render() {
Expand All @@ -8,6 +9,14 @@ export default class MyDocument extends Document {
return (
<Html lang="en">
<Head>
{/* Apply theme before first paint so dark-mode users
don't see a flash of light content while hydrating.
Source: src/contexts/theme-context.tsx */}
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: Inline pre-paint script, source-controlled constant.
dangerouslySetInnerHTML={{ __html: THEME_PREPAINT_SCRIPT }}
/>

{/* PWA Meta Tags */}
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
Expand All @@ -20,9 +29,27 @@ export default class MyDocument extends Document {
<link rel="apple-touch-icon" href="/images/favicon.png" />

{/* Preload GothamPro fonts */}
<link rel="preload" href="/fonts/gotham/GothamPro.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
<link rel="preload" href="/fonts/gotham/GothamPro-Bold.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
<link rel="preload" href="/fonts/gotham/GothamPro-Medium.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
<link
rel="preload"
href="/fonts/gotham/GothamPro.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/gotham/GothamPro-Bold.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/gotham/GothamPro-Medium.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>

<link
rel="stylesheet"
Expand Down
Loading