diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 8ce442359..57e112094 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -9,7 +9,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
-import { THEME_COLOR } from "@/lib/pwa/theme-color";
+import { THEME_BOOT_SCRIPT, THEME_COLOR } from "@/lib/pwa/theme-color";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
@@ -80,9 +80,6 @@ export const viewport: Viewport = {
],
};
-// Inline script to apply theme before first paint (prevents FOUC)
-const themeScript = `(function(){try{var t=localStorage.getItem("healthlog-theme");var c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark");document.documentElement.classList.add(c)}catch(e){document.documentElement.classList.add("dark")}})()`;
-
export default async function RootLayout({
children,
}: Readonly<{
@@ -101,7 +98,9 @@ export default async function RootLayout({
{/*
Locale-catalog boot script (generated into public/i18n/ by
diff --git a/src/lib/pwa/__tests__/theme-color.test.ts b/src/lib/pwa/__tests__/theme-color.test.ts
index 28eddac9b..7d2a846a8 100644
--- a/src/lib/pwa/__tests__/theme-color.test.ts
+++ b/src/lib/pwa/__tests__/theme-color.test.ts
@@ -3,7 +3,11 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
-import { applyThemeColor, THEME_COLOR } from "@/lib/pwa/theme-color";
+import {
+ applyThemeColor,
+ THEME_BOOT_SCRIPT,
+ THEME_COLOR,
+} from "@/lib/pwa/theme-color";
function fakeMeta(content: string) {
const attrs = new Map([["content", content]]);
@@ -46,3 +50,50 @@ describe("applyThemeColor", () => {
);
});
});
+
+describe("THEME_BOOT_SCRIPT", () => {
+ function boot(stored: string | null, systemDark: boolean) {
+ const metas = [fakeMeta(THEME_COLOR.light), fakeMeta(THEME_COLOR.dark)];
+ const classes = new Set();
+ const listeners = new Map void>();
+ const document = {
+ documentElement: { classList: { add: (c: string) => classes.add(c) } },
+ querySelectorAll: () => metas,
+ addEventListener: (type: string, fn: () => void) =>
+ listeners.set(type, fn),
+ };
+ const window = {
+ matchMedia: () => ({ matches: systemDark }),
+ };
+ const localStorage = { getItem: () => stored };
+ new Function("window", "document", "localStorage", THEME_BOOT_SCRIPT)(
+ window,
+ document,
+ localStorage,
+ );
+ return { metas, classes, listeners };
+ }
+
+ it("paints the status bar in the app's theme before hydration", () => {
+ // A light system, the app in its dark default: the light-keyed tag is
+ // the one the browser applies, so it must carry the dark colour already.
+ const { metas, classes } = boot(null, false);
+ expect([...classes]).toEqual(["dark"]);
+ for (const meta of metas) {
+ expect(meta.attrs.get("content")).toBe(THEME_COLOR.dark);
+ }
+ });
+
+ it("follows a stored light choice on a dark system", () => {
+ const { metas, classes } = boot("light", true);
+ expect([...classes]).toEqual(["light"]);
+ for (const meta of metas) {
+ expect(meta.attrs.get("content")).toBe(THEME_COLOR.light);
+ }
+ });
+
+ it("repeats the rewrite once the document is parsed", () => {
+ const { listeners } = boot("system", true);
+ expect(listeners.has("DOMContentLoaded")).toBe(true);
+ });
+});
diff --git a/src/lib/pwa/theme-color.ts b/src/lib/pwa/theme-color.ts
index 1284c852c..3e8ca8eda 100644
--- a/src/lib/pwa/theme-color.ts
+++ b/src/lib/pwa/theme-color.ts
@@ -28,3 +28,14 @@ export function applyThemeColor(
meta.setAttribute("content", THEME_COLOR[resolved]);
}
}
+
+/**
+ * The inline boot script the root layout runs before first paint. It stamps
+ * the theme class (no flash of the wrong theme) and rewrites the theme-color
+ * tags to that theme, which until now waited for hydration: a phone in light
+ * mode running the app in dark mode showed a light status bar until React
+ * caught up. The tags may stream in after the script, so it repeats the
+ * rewrite once the document is parsed. Built from constants only — the
+ * layout's one `dangerouslySetInnerHTML` stays file-literal content.
+ */
+export const THEME_BOOT_SCRIPT = `(function(){var c="dark";try{var t=localStorage.getItem("healthlog-theme");c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark")}catch(e){}document.documentElement.classList.add(c);var k=c==="light"?${JSON.stringify(THEME_COLOR.light)}:${JSON.stringify(THEME_COLOR.dark)};function m(){var a=document.querySelectorAll('meta[name="theme-color"]');for(var i=0;i