Skip to content
Closed
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>` 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:
Expand Down
8 changes: 7 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -28,8 +29,13 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const themeInitScript = buildThemeInitScript();

return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body>
<a
href="#main-content"
Expand Down
80 changes: 80 additions & 0 deletions src/lib/__tests__/themeScript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { buildThemeInitScript } from "@/lib/themeScript";
import { THEME_STORAGE_KEY } from "@/lib/theme";

function installMatchMedia(prefersDark: boolean) {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
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();
});
});
6 changes: 3 additions & 3 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -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" {
Expand Down
10 changes: 10 additions & 0 deletions src/lib/themeScript.ts
Original file line number Diff line number Diff line change
@@ -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){}})();`;
}