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(/]*\bsrc=)[^>]*>/); + + expect(boot).not.toBeNull(); + expect(boot?.[0]).not.toContain("module"); + expect(boot?.[0]).not.toContain("defer"); + }); + + test("the boot script applies the dark class itself", () => { + expect(html).toContain("documentElement"); + expect(html).toMatch(/classList[\s\S]*dark/); + }); + + test("the document declares a color scheme before the stylesheet arrives", () => { + expect(html).toContain("colorScheme"); + }); +}); + +describe("color scheme", () => { + const styles = readFileSync( + new URL("../src/styles.css", import.meta.url), + "utf8", + ); + + test("both themes tell the browser which one they are", () => { + expect(styles).toMatch(/:root\s*\{[\s\S]*?color-scheme:\s*light/); + expect(styles).toMatch(/\.dark\s*\{[\s\S]*?color-scheme:\s*dark/); }); });