Skip to content

Commit 2dc6c2f

Browse files
authored
Decide the theme before the first paint, not after it (#204)
* Decide the theme before the first paint, not after it * Record the theme flash fix in the changelog
1 parent 342a292 commit 2dc6c2f

6 files changed

Lines changed: 72 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,20 @@ nothing accounts for — a turn that silently disappears reads as one that was n
221221
content and every well-formed tool call are unaffected, and a history that cannot be read at all
222222
still opens the composer rather than blocking it.
223223

224+
### Refreshing no longer flashes white before the theme arrives
225+
226+
A person with the dark theme selected saw a white frame on every reload. The stored preference was
227+
read early enough, but it was applied one paint too late: the browser had already drawn a frame
228+
against the light palette by the time the app got to it. The document now decides its theme before
229+
anything is drawn.
230+
231+
The browser was also drawing its own surfaces — scrollbars, form controls, the overscroll area —
232+
light under a dark app, for the whole session rather than for a frame. Both themes now declare which
233+
one they are, so those match too.
234+
235+
No configuration changes and nothing is stored differently; a deployment that was already on the
236+
light theme sees no difference at all.
237+
224238
## 0.0.4
225239

226240
### A click citing a ref this deployment cannot resolve is refused

app/index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>OpenBot</title>
7+
<!-- Sets the theme before the first paint. Must stay inline and classic: `defer` or `type="module"` runs too late. -->
8+
<script>
9+
// localStorage throws in some privacy modes; a theme is not worth taking the app down for.
10+
try {
11+
const dark = window.localStorage.getItem("openbot-theme") === "dark";
12+
document.documentElement.classList.toggle("dark", dark);
13+
document.documentElement.style.colorScheme = dark ? "dark" : "light";
14+
} catch {}
15+
</script>
716
</head>
817
<body>
918
<div id="root"></div>

app/src/components/theme-provider.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
2828
setStoredValue: (key, value) => window.localStorage.setItem(key, value),
2929
toggleRootClass: (name, force) =>
3030
document.documentElement.classList.toggle(name, force),
31+
setRootColorScheme: (scheme) => {
32+
document.documentElement.style.colorScheme = scheme;
33+
},
3134
});
3235
}, [dark]);
3336

app/src/lib/theme.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ export function parseStoredDarkTheme(value: string | null) {
77
type ThemeEffects = {
88
setStoredValue: (key: string, value: string) => void;
99
toggleRootClass: (name: string, force: boolean) => void;
10+
setRootColorScheme: (scheme: "dark" | "light") => void;
1011
};
1112

1213
export function applyDarkTheme(dark: boolean, effects: ThemeEffects) {
1314
effects.setStoredValue(THEME_STORAGE_KEY, dark ? "dark" : "light");
1415
effects.toggleRootClass("dark", dark);
16+
// `index.html` sets this inline before paint, and an inline style outranks the palette.
17+
effects.setRootColorScheme(dark ? "dark" : "light");
1518
}

app/src/styles.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ html {
1919
}
2020

2121
:root {
22+
color-scheme: light;
2223
--background: oklch(0.985 0 0);
2324
--foreground: oklch(0.145 0 0);
2425
--card: oklch(1 0 0);
@@ -55,6 +56,7 @@ html {
5556
}
5657

5758
.dark {
59+
color-scheme: dark;
5860
--background: oklch(0.145 0 0);
5961
--foreground: oklch(0.985 0 0);
6062
--card: oklch(0.205 0 0);

app/tests/theme-preference.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFileSync } from "node:fs";
12
import { describe, expect, test } from "bun:test";
23
import {
34
applyDarkTheme,
@@ -15,13 +16,53 @@ describe("theme preference", () => {
1516
test("persists and applies the selected theme", () => {
1617
const writes: Array<[string, string]> = [];
1718
const toggles: Array<[string, boolean]> = [];
19+
const schemes: Array<string> = [];
1820

1921
applyDarkTheme(true, {
2022
setStoredValue: (key, value) => writes.push([key, value]),
2123
toggleRootClass: (name, force) => toggles.push([name, force]),
24+
setRootColorScheme: (scheme) => schemes.push(scheme),
2225
});
2326

2427
expect(writes).toEqual([[THEME_STORAGE_KEY, "dark"]]);
2528
expect(toggles).toEqual([["dark", true]]);
29+
expect(schemes).toEqual(["dark"]);
30+
});
31+
});
32+
33+
describe("pre-paint theme boot", () => {
34+
const html = readFileSync(new URL("../index.html", import.meta.url), "utf8");
35+
36+
test("the boot script reads the same storage key the app writes", () => {
37+
expect(html).toContain(THEME_STORAGE_KEY);
38+
});
39+
40+
test("the boot script runs before the first paint", () => {
41+
const boot = html.match(/<script(?![^>]*\bsrc=)[^>]*>/);
42+
43+
expect(boot).not.toBeNull();
44+
expect(boot?.[0]).not.toContain("module");
45+
expect(boot?.[0]).not.toContain("defer");
46+
});
47+
48+
test("the boot script applies the dark class itself", () => {
49+
expect(html).toContain("documentElement");
50+
expect(html).toMatch(/classList[\s\S]*dark/);
51+
});
52+
53+
test("the document declares a color scheme before the stylesheet arrives", () => {
54+
expect(html).toContain("colorScheme");
55+
});
56+
});
57+
58+
describe("color scheme", () => {
59+
const styles = readFileSync(
60+
new URL("../src/styles.css", import.meta.url),
61+
"utf8",
62+
);
63+
64+
test("both themes tell the browser which one they are", () => {
65+
expect(styles).toMatch(/:root\s*\{[\s\S]*?color-scheme:\s*light/);
66+
expect(styles).toMatch(/\.dark\s*\{[\s\S]*?color-scheme:\s*dark/);
2667
});
2768
});

0 commit comments

Comments
 (0)