Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenBot</title>
<!-- Sets the theme before the first paint. Must stay inline and classic: `defer` or `type="module"` runs too late. -->
<script>
// localStorage throws in some privacy modes; a theme is not worth taking the app down for.
try {
const dark = window.localStorage.getItem("openbot-theme") === "dark";
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
} catch {}
</script>
</head>
<body>
<div id="root"></div>
Expand Down
3 changes: 3 additions & 0 deletions app/src/components/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
2 changes: 2 additions & 0 deletions app/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions app/tests/theme-preference.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
import {
applyDarkTheme,
Expand All @@ -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<string> = [];

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(/<script(?![^>]*\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/);
});
});