diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5735d69f..fb11af23 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -221,6 +221,20 @@ nothing accounts for — a turn that silently disappears reads as one that was n
content and every well-formed tool call are unaffected, and a history that cannot be read at all
still opens the composer rather than blocking it.
+### Refreshing no longer flashes white before the theme arrives
+
+A person with the dark theme selected saw a white frame on every reload. The stored preference was
+read early enough, but it was applied one paint too late: the browser had already drawn a frame
+against the light palette by the time the app got to it. The document now decides its theme before
+anything is drawn.
+
+The browser was also drawing its own surfaces — scrollbars, form controls, the overscroll area —
+light under a dark app, for the whole session rather than for a frame. Both themes now declare which
+one they are, so those match too.
+
+No configuration changes and nothing is stored differently; a deployment that was already on the
+light theme sees no difference at all.
+
## 0.0.4
### A click citing a ref this deployment cannot resolve is refused
diff --git a/app/index.html b/app/index.html
index cd14d719..56293c4b 100644
--- a/app/index.html
+++ b/app/index.html
@@ -4,6 +4,15 @@
OpenBot
+
+
diff --git a/app/src/components/theme-provider.tsx b/app/src/components/theme-provider.tsx
index d92a32e3..9806bb81 100644
--- a/app/src/components/theme-provider.tsx
+++ b/app/src/components/theme-provider.tsx
@@ -28,6 +28,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setStoredValue: (key, value) => window.localStorage.setItem(key, value),
toggleRootClass: (name, force) =>
document.documentElement.classList.toggle(name, force),
+ setRootColorScheme: (scheme) => {
+ document.documentElement.style.colorScheme = scheme;
+ },
});
}, [dark]);
diff --git a/app/src/lib/theme.ts b/app/src/lib/theme.ts
index b6424541..59f663fe 100644
--- a/app/src/lib/theme.ts
+++ b/app/src/lib/theme.ts
@@ -7,9 +7,12 @@ export function parseStoredDarkTheme(value: string | null) {
type ThemeEffects = {
setStoredValue: (key: string, value: string) => void;
toggleRootClass: (name: string, force: boolean) => void;
+ setRootColorScheme: (scheme: "dark" | "light") => void;
};
export function applyDarkTheme(dark: boolean, effects: ThemeEffects) {
effects.setStoredValue(THEME_STORAGE_KEY, dark ? "dark" : "light");
effects.toggleRootClass("dark", dark);
+ // `index.html` sets this inline before paint, and an inline style outranks the palette.
+ effects.setRootColorScheme(dark ? "dark" : "light");
}
diff --git a/app/src/styles.css b/app/src/styles.css
index 6ca61791..66d7cb19 100644
--- a/app/src/styles.css
+++ b/app/src/styles.css
@@ -19,6 +19,7 @@ html {
}
:root {
+ color-scheme: light;
--background: oklch(0.985 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -55,6 +56,7 @@ html {
}
.dark {
+ color-scheme: dark;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
diff --git a/app/tests/theme-preference.test.ts b/app/tests/theme-preference.test.ts
index 52eeaf40..1e807e2e 100644
--- a/app/tests/theme-preference.test.ts
+++ b/app/tests/theme-preference.test.ts
@@ -1,3 +1,4 @@
+import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
import {
applyDarkTheme,
@@ -15,13 +16,53 @@ describe("theme preference", () => {
test("persists and applies the selected theme", () => {
const writes: Array<[string, string]> = [];
const toggles: Array<[string, boolean]> = [];
+ const schemes: Array = [];
applyDarkTheme(true, {
setStoredValue: (key, value) => writes.push([key, value]),
toggleRootClass: (name, force) => toggles.push([name, force]),
+ setRootColorScheme: (scheme) => schemes.push(scheme),
});
expect(writes).toEqual([[THEME_STORAGE_KEY, "dark"]]);
expect(toggles).toEqual([["dark", true]]);
+ expect(schemes).toEqual(["dark"]);
+ });
+});
+
+describe("pre-paint theme boot", () => {
+ const html = readFileSync(new URL("../index.html", import.meta.url), "utf8");
+
+ test("the boot script reads the same storage key the app writes", () => {
+ expect(html).toContain(THEME_STORAGE_KEY);
+ });
+
+ test("the boot script runs before the first paint", () => {
+ const boot = html.match(/