diff --git a/README.md b/README.md index 3ab7b96..1498416 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ Next.js app for [StableRoute](https://github.com/your-org/stableroute) — Stell | `npm test` | Run Jest tests | | `npm run lint` | Next.js ESLint | +## Theme bootstrapping + +The root layout includes a tiny inline script that reads `stableroute.theme` +before React hydrates. It applies the `dark` class to `` immediately for +stored dark preferences and for `system` when `prefers-color-scheme: dark` +matches, which avoids a light-to-dark flash on first paint. + ## CI/CD On every push/PR to `main`, GitHub Actions runs: diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a926a30..28ce5c2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import { Header } from "@/components/Header"; import { Footer } from "@/components/Footer"; import { ToastProvider } from "@/components/ToastProvider"; +import { buildThemeInitScript } from "@/lib/themeScript"; export const metadata: Metadata = { title: { @@ -28,8 +29,13 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const themeInitScript = buildThemeInitScript(); + return ( - + +
+ + ({ + matches: prefersDark, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +} + +function runThemeScript() { + new Function(buildThemeInitScript())(); +} + +describe("buildThemeInitScript", () => { + beforeEach(() => { + window.localStorage.clear(); + document.documentElement.className = ""; + installMatchMedia(false); + }); + + it("applies the stored dark theme before hydration", () => { + window.localStorage.setItem(THEME_STORAGE_KEY, "dark"); + + runThemeScript(); + + expect(document.documentElement).toHaveClass("dark"); + }); + + it("removes dark mode when the stored theme is light", () => { + document.documentElement.classList.add("dark"); + window.localStorage.setItem(THEME_STORAGE_KEY, "light"); + + runThemeScript(); + + expect(document.documentElement).not.toHaveClass("dark"); + }); + + it("uses prefers-color-scheme when the stored theme is system", () => { + installMatchMedia(true); + window.localStorage.setItem(THEME_STORAGE_KEY, "system"); + + runThemeScript(); + + expect(window.matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)"); + expect(document.documentElement).toHaveClass("dark"); + }); + + it("falls back to system for invalid stored values", () => { + installMatchMedia(true); + window.localStorage.setItem(THEME_STORAGE_KEY, "blue"); + + runThemeScript(); + + expect(document.documentElement).toHaveClass("dark"); + }); + + it("does not throw if localStorage is unavailable", () => { + const getItem = jest + .spyOn(Storage.prototype, "getItem") + .mockImplementation(() => { + throw new Error("blocked"); + }); + + expect(() => runThemeScript()).not.toThrow(); + expect(document.documentElement).not.toHaveClass("dark"); + + getItem.mockRestore(); + }); +}); diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 736f83d..0fbe3a6 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,16 +1,16 @@ export type Theme = "light" | "dark" | "system"; -const KEY = "stableroute.theme"; +export const THEME_STORAGE_KEY = "stableroute.theme"; export function readTheme(): Theme { if (typeof window === "undefined") return "system"; - const v = window.localStorage.getItem(KEY); + const v = window.localStorage.getItem(THEME_STORAGE_KEY); return v === "light" || v === "dark" || v === "system" ? v : "system"; } export function writeTheme(theme: Theme) { if (typeof window === "undefined") return; - window.localStorage.setItem(KEY, theme); + window.localStorage.setItem(THEME_STORAGE_KEY, theme); } export function effectiveTheme(theme: Theme): "light" | "dark" { diff --git a/src/lib/themeScript.ts b/src/lib/themeScript.ts new file mode 100644 index 0000000..fa93295 --- /dev/null +++ b/src/lib/themeScript.ts @@ -0,0 +1,10 @@ +import { THEME_STORAGE_KEY } from "@/lib/theme"; + +/** + * Builds the tiny inline script that applies the stored theme before React hydrates. + */ +export function buildThemeInitScript() { + return `(function(){try{var key=${JSON.stringify( + THEME_STORAGE_KEY, + )};var stored;try{stored=window.localStorage.getItem(key);}catch(e){}var theme=stored==="light"||stored==="dark"||stored==="system"?stored:"system";var prefersDark=false;try{prefersDark=!!(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches);}catch(e){}document.documentElement.classList.toggle("dark",theme==="dark"||(theme==="system"&&prefersDark));}catch(e){}})();`; +}