Skip to content

Commit 3c00b92

Browse files
committed
feat(app): light/dark/system theme toggle (#107)
The token set already carried light + dark values but the app was pinned to dark with no way to switch. Adds lib/theme.ts (a useSyncExternalStore store persisting pyops.theme; applies the .dark class + color-scheme, tracks the OS setting while on 'system'), a pre-paint script in the root document so switching never flashes the wrong palette, and a Settings → Display theme selector. Audited for raw-palette leaks (none — the design-system guard has held), so every surface inherits the light tokens correctly. Mechanism verified: theme.test.ts (persist/apply/system-resolve) and an e2e that the class + color-scheme flip and survive a reload. NOT done here: the pixel-level light-mode contrast pass across every route — that is an inherently visual review left to a human (the toggle now makes it possible to do). The issue's other option (delete the light tokens as dead) is thus moot: they're live and reachable. Refs #107
1 parent 20dc077 commit 3c00b92

7 files changed

Lines changed: 205 additions & 2 deletions

File tree

app/e2e/theme.e2e.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
/**
4+
* Theme toggle (#107): Settings → Display → Theme flips the `.dark` class and
5+
* `color-scheme` on <html> and persists across a reload. This exercises the
6+
* MECHANISM; the pixel-level light-mode contrast pass is a human visual review.
7+
*/
8+
test("theme toggle flips the dark class and persists", async ({ page }) => {
9+
await page.goto("/settings?tab=planning");
10+
11+
const html = page.locator("html");
12+
await expect(html).toHaveClass(/dark/); // dark is the default
13+
14+
// switch to light
15+
// the theme Select trigger shows the current value; open it and pick Light
16+
await page.locator('[data-slot="select-trigger"]').filter({ hasText: /Dark|Light|System/ }).first().click();
17+
await page.getByRole("option", { name: "Light" }).click();
18+
await expect(html).not.toHaveClass(/dark/);
19+
await expect(html).toHaveJSProperty("style.colorScheme", "light");
20+
21+
// the pre-paint script keeps it light across a reload (no flash back to dark)
22+
await page.reload();
23+
await expect(html).not.toHaveClass(/dark/);
24+
25+
// back to dark for the rest of the suite's assumptions
26+
await page.goto("/settings?tab=planning");
27+
await page.getByRole("combobox").filter({ hasText: /Dark|Light|System/ }).first().click();
28+
await page.getByRole("option", { name: "Dark" }).click();
29+
await expect(html).toHaveClass(/dark/);
30+
});

app/src/lib/theme.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
3+
/** The theme store drives the `.dark` class + `color-scheme` off a persisted
4+
* light/dark/system preference (#107). Reset modules between cases so each gets
5+
* a fresh store initialized from the stubbed localStorage. */
6+
describe("theme store", () => {
7+
let toggleSpy: ReturnType<typeof vi.fn>;
8+
beforeEach(() => {
9+
const store: Record<string, string> = {};
10+
vi.stubGlobal("localStorage", {
11+
getItem: (k: string) => store[k] ?? null,
12+
setItem: (k: string, v: string) => void (store[k] = v),
13+
removeItem: (k: string) => void delete store[k],
14+
});
15+
toggleSpy = vi.fn();
16+
const root = { classList: { toggle: toggleSpy }, style: { colorScheme: "" } };
17+
vi.stubGlobal("document", { documentElement: root });
18+
vi.stubGlobal("matchMedia", () => ({
19+
matches: false, // system → light in tests
20+
addEventListener: vi.fn(),
21+
removeEventListener: vi.fn(),
22+
}));
23+
});
24+
afterEach(() => {
25+
vi.unstubAllGlobals();
26+
vi.resetModules();
27+
});
28+
29+
it("defaults to dark when nothing is stored", async () => {
30+
const { getTheme, resolvedTheme } = await import("./theme.ts");
31+
expect(getTheme()).toBe("dark");
32+
expect(resolvedTheme()).toBe("dark");
33+
});
34+
35+
it("setTheme persists, applies the class, and notifies subscribers", async () => {
36+
const { setTheme, getTheme, subscribeTheme } = await import("./theme.ts");
37+
const fn = vi.fn();
38+
subscribeTheme(fn);
39+
setTheme("light");
40+
expect(getTheme()).toBe("light");
41+
expect(localStorage.getItem("pyops.theme")).toBe("light");
42+
expect(toggleSpy).toHaveBeenCalledWith("dark", false);
43+
expect(document.documentElement.style.colorScheme).toBe("light");
44+
expect(fn).toHaveBeenCalled();
45+
});
46+
47+
it("system resolves via matchMedia (light when the OS is light)", async () => {
48+
const { setTheme, resolvedTheme } = await import("./theme.ts");
49+
setTheme("system");
50+
expect(resolvedTheme()).toBe("light");
51+
expect(document.documentElement.style.colorScheme).toBe("light");
52+
});
53+
54+
it("reads a stored preference on init", async () => {
55+
localStorage.setItem("pyops.theme", "light");
56+
const { getTheme } = await import("./theme.ts");
57+
expect(getTheme()).toBe("light");
58+
});
59+
});

app/src/lib/theme.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Theme preference (#107): light / dark / system, a per-browser display choice
3+
* stored in localStorage — `useSyncExternalStore`-shaped like the number-format
4+
* store. `applyTheme` sets the `.dark` class and `color-scheme` on <html> so
5+
* every token (and native controls/scrollbars) follow; it also tracks the OS
6+
* preference while on "system". The token values themselves live in styles.css
7+
* (`:root` = light, `.dark` = dark).
8+
*/
9+
10+
export type ThemePref = "light" | "dark" | "system";
11+
12+
const KEY = "pyops.theme";
13+
const isPref = (v: unknown): v is ThemePref => v === "light" || v === "dark" || v === "system";
14+
15+
let pref: ThemePref = (() => {
16+
if (typeof localStorage === "undefined") return "dark";
17+
const v = localStorage.getItem(KEY);
18+
return isPref(v) ? v : "dark"; // dark is the historical default
19+
})();
20+
21+
const listeners = new Set<() => void>();
22+
23+
const mql = () =>
24+
typeof matchMedia === "undefined" ? null : matchMedia("(prefers-color-scheme: dark)");
25+
26+
/** The concrete mode a preference resolves to right now. */
27+
export function resolvedTheme(p: ThemePref = pref): "light" | "dark" {
28+
if (p !== "system") return p;
29+
return mql()?.matches ? "dark" : "light";
30+
}
31+
32+
/** Paint the resolved theme onto <html>. Safe to call before React mounts. */
33+
export function applyTheme(p: ThemePref = pref) {
34+
if (typeof document === "undefined") return;
35+
const mode = resolvedTheme(p);
36+
const root = document.documentElement;
37+
root.classList.toggle("dark", mode === "dark");
38+
root.style.colorScheme = mode; // native controls + scrollbars
39+
}
40+
41+
export const getTheme = () => pref;
42+
43+
export function setTheme(p: ThemePref) {
44+
pref = p;
45+
if (typeof localStorage !== "undefined") localStorage.setItem(KEY, p);
46+
applyTheme(p);
47+
for (const l of listeners) l();
48+
}
49+
50+
export function subscribeTheme(fn: () => void): () => void {
51+
listeners.add(fn);
52+
// while on "system", OS changes must re-resolve — mirror them to subscribers
53+
const m = mql();
54+
const onOs = () => {
55+
if (pref === "system") {
56+
applyTheme();
57+
fn();
58+
}
59+
};
60+
m?.addEventListener?.("change", onOs);
61+
listeners.add(fn);
62+
return () => {
63+
listeners.delete(fn);
64+
m?.removeEventListener?.("change", onOs);
65+
};
66+
}

app/src/routes/__root.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ function RootDocument({ children }: { children: React.ReactNode }) {
6262
<html lang="en" className="dark">
6363
<head>
6464
<HeadContent />
65+
{/* Pre-paint theme (#107): correct the SSR'd `dark` class before first
66+
paint from the stored pyops.theme preference, so switching to light /
67+
system never flashes the wrong palette. Mirrors lib/theme.ts. */}
68+
<script
69+
dangerouslySetInnerHTML={{
70+
__html:
71+
"(function(){try{var p=localStorage.getItem('pyops.theme')||'dark';" +
72+
"var d=p==='dark'||(p==='system'&&matchMedia('(prefers-color-scheme: dark)').matches);" +
73+
"var r=document.documentElement;r.classList.toggle('dark',d);" +
74+
"r.style.colorScheme=d?'dark':'light';}catch(e){}})();",
75+
}}
76+
/>
6577
</head>
6678
<body>
6779
<div className="flex h-screen flex-col">

app/src/routes/settings.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ import {
3939
setCompactNumbers,
4040
subscribeNumberFormat,
4141
} from "../lib/format";
42+
import { getTheme, setTheme, subscribeTheme, type ThemePref } from "../lib/theme";
43+
import {
44+
Select,
45+
SelectContent,
46+
SelectItem,
47+
SelectTrigger,
48+
SelectValue,
49+
} from "#/components/ui/select.tsx";
4250

4351
const TABS = [
4452
{ id: "planning", label: "Planning" },
@@ -394,12 +402,31 @@ const PAYBACK_PRESETS = [
394402
* not project data — it changes nothing about the plan, only how it reads. */
395403
function DisplayCard() {
396404
const compact = useSyncExternalStore(subscribeNumberFormat, getCompactNumbers, () => true);
405+
const theme = useSyncExternalStore(subscribeTheme, getTheme, () => "dark" as ThemePref);
397406
return (
398407
<Card>
399408
<CardHeader>
400409
<CardTitle>Display</CardTitle>
401410
</CardHeader>
402411
<div className="space-y-3 px-3 pb-3">
412+
<label className="flex items-center justify-between gap-3">
413+
<span>
414+
Theme
415+
<span className="block text-sm text-muted-foreground">
416+
light, dark, or follow your system setting
417+
</span>
418+
</span>
419+
<Select value={theme} onValueChange={(v) => setTheme(v as ThemePref)}>
420+
<SelectTrigger className="w-32">
421+
<SelectValue />
422+
</SelectTrigger>
423+
<SelectContent>
424+
<SelectItem value="dark">Dark</SelectItem>
425+
<SelectItem value="light">Light</SelectItem>
426+
<SelectItem value="system">System</SelectItem>
427+
</SelectContent>
428+
</Select>
429+
</label>
403430
<label className="flex items-center justify-between gap-3">
404431
<span>
405432
Compact large numbers

app/src/styles.css

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,10 @@ body,
159159
regardless of the preset's @import order. The @layer/@apply version was being
160160
overridden, which left body text/borders falling back to currentColor. */
161161
html {
162-
color-scheme: dark; /* native controls + scrollbars render dark */
162+
/* color-scheme is set by the pre-paint theme script + lib/theme.ts (#107),
163+
following the light/dark/system preference; dark is the fallback here for
164+
the brief moment before that runs. */
165+
color-scheme: dark;
163166
font-family: var(--font-mono);
164167
/* Themed scrollbars: thin, muted thumb on a transparent track — Chromium's
165168
stock grey glows against the dark theme. Inherits to every scrollable. */

docs/design.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ If you're unsure whether something is fine print, it isn't — use `text-sm`.
4343

4444
Use theme tokens only. Never raw palette classes (`text-emerald-300`,
4545
`bg-zinc-800`) or hex values — they don't adapt to light/dark and drift shade by
46-
shade. The tokens (defined in `styles.css`, light + dark values each):
46+
shade. The tokens (defined in `styles.css`, `:root` = light, `.dark` = dark).
47+
The active theme is a per-browser preference (light / dark / **system**) — set
48+
in Settings → Display, stored at `pyops.theme`, applied by `lib/theme.ts` (which
49+
toggles the `.dark` class + `color-scheme`) with a pre-paint script in the root
50+
document that reads it before first paint so switching never flashes. Dark is
51+
the default and the mode the UI is tuned in first; new surfaces must read
52+
correctly in both (that's what token-only buys you). The tokens:
4753

4854
| Token | Meaning in PyOps |
4955
| --- | --- |

0 commit comments

Comments
 (0)