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
5 changes: 3 additions & 2 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ this is shared styling, not a second component registry or a parallel `core/` tr

## First release contract

Settings → Appearance offers **Light** and **Dark**, defaulting to Light. The choice
Settings → Appearance offers **Light**, **Dark**, and **System**, defaulting to Light. System

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required DCO sign-off

The reviewed commit message contains no Signed-off-by trailer, so this commit violates the repository's per-commit DCO requirement and cannot pass the hosted DCO Check. Recreate or sign off this commit using the actual author's verified Git identity before pushing it.

AGENTS.md reference: AGENTS.md:L153-L163

Useful? React with 👍 / 👎.

follows the computer's color scheme as it changes. The choice
is device-local (`buzz-appearance.v1` in browser-origin localStorage), not a community
profile or relay event. There is no System mode, theme marketplace or appearance sync
profile or relay event. There is no theme marketplace or appearance sync
between devices. Another same-origin window observes saved changes without rebuilding
pages or relay services. Failed storage reads open safely in Light; failed saves apply
for this session and expose a retry in Appearance. Invalid stored values use Light.
Expand Down
8 changes: 7 additions & 1 deletion public/appearance-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
(() => {
let mode = "light";
try {
if (localStorage.getItem("buzz-appearance.v1") === "dark") mode = "dark";
const preference = localStorage.getItem("buzz-appearance.v1");
if (preference === "dark") mode = "dark";
if (
preference === "system" &&
matchMedia("(prefers-color-scheme: dark)").matches
)
mode = "dark";
} catch {
// Storage may be denied; the built-in light palette still opens safely.
}
Expand Down
22 changes: 22 additions & 0 deletions src/app/AppearanceSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,25 @@ it("hidden settings do not portal errors; returning retains both save recovery a
expect(localStorage.getItem("buzz-font-scale.v1")).toBe("1.2");
appearance.dispose();
});

it("retry saves the System choice even when its current palette is light", async () => {
const appearance = createAppearance();
const write = vi
.spyOn(Storage.prototype, "setItem")
.mockImplementationOnce(() => {
throw new Error("denied");
});
act(() => appearance.setMode("system"));
render(
<ToastProvider>
<AppearanceSettings appearance={appearance} />
</ToastProvider>,
);
expect(screen.getByRole("radio", { name: "System" })).toBeChecked();
fireEvent.click(
screen.getByRole("button", { name: "Retry saving appearance" }),
);
expect(write).toHaveBeenLastCalledWith("buzz-appearance.v1", "system");
expect(localStorage.getItem("buzz-appearance.v1")).toBe("system");
appearance.dispose();
});
18 changes: 10 additions & 8 deletions src/app/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { Field } from "../shared/design-system/ui/Field";
import { Radio, RadioGroup } from "../shared/design-system/ui/RadioGroup";
import { Button } from "../shared/design-system/ui/Button";
import { useSyncExternalStore } from "react";
import { MoonIcon, SunIcon } from "../shared/design-system/icons/index";
import {
MonitorIcon,
MoonIcon,
SunIcon,
} from "../shared/design-system/icons/index";
import type { Appearance } from "../shared/theme/service";

/** Shared radios provide one Tab stop and standard arrow-key selection. */
Expand All @@ -14,7 +18,7 @@ export function AppearanceSettings({
appearance: Appearance;
active?: boolean;
}) {
const { mode, error, fontScale, fontError } = useSyncExternalStore(
const { preference, error, fontScale, fontError } = useSyncExternalStore(
appearance.subscribe,
appearance.snapshot,
);
Expand All @@ -24,19 +28,17 @@ export function AppearanceSettings({
Appearance
</h2>
<div>
<Field
label="Color mode"
description="Choose how Buzz looks on this device. Your choice is saved automatically."
>
<Field label="Color mode">
Comment thread
klopez4212 marked this conversation as resolved.
<RadioGroup
name="color-mode"
value={mode}
value={preference}
onValueChange={(value) => appearance.setMode(value)}
>
{(
[
["light", "Light", SunIcon],
["dark", "Dark", MoonIcon],
["system", "System", MonitorIcon],
] as const
).map(([value, label, Icon]) => (
<Radio
Expand Down Expand Up @@ -99,7 +101,7 @@ export function AppearanceSettings({
<Button
type="button"
size="sm"
onClick={() => appearance.setMode(mode)}
onClick={() => appearance.setMode(preference)}
>
Retry saving appearance
</Button>
Expand Down
2 changes: 2 additions & 0 deletions src/shared/design-system/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ import { MinusIcon as PhosphorMinusIcon } from "@phosphor-icons/react/dist/csr/M
export const MinusIcon = defineIcon("phosphor", PhosphorMinusIcon);
import { MoonIcon as PhosphorMoonIcon } from "@phosphor-icons/react/dist/csr/Moon";
export const MoonIcon = defineIcon("phosphor", PhosphorMoonIcon);
import { MonitorIcon as PhosphorMonitorIcon } from "@phosphor-icons/react/dist/csr/Monitor";
export const MonitorIcon = defineIcon("phosphor", PhosphorMonitorIcon);
import { NotionLogoIcon as PhosphorNotionLogoIcon } from "@phosphor-icons/react/dist/csr/NotionLogo";
export const NotionLogoIcon = defineIcon("phosphor", PhosphorNotionLogoIcon);
import { PaletteIcon as PhosphorPaletteIcon } from "@phosphor-icons/react/dist/csr/Palette";
Expand Down
76 changes: 67 additions & 9 deletions src/shared/theme/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import {
FONT_SCALE_KEY,
createAppearance,
parseColorMode,
parseColorModePreference,
parseFontScale,
} from "./service";

function browser(stored: string | null = null) {
function browser(stored: string | null = null, systemDark = false) {
const values = new Map(stored === null ? [] : [[APPEARANCE_KEY, stored]]);
const listeners = new Set<(event: StorageEvent) => void>();
const root = {
Expand All @@ -21,8 +22,19 @@ function browser(stored: string | null = null) {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
};
let dark = systemDark;
const schemeListeners = new Set<() => void>();
const scheme = {
get matches() {
return dark;
},
addEventListener: (_: string, fn: () => void) => schemeListeners.add(fn),
removeEventListener: (_: string, fn: () => void) =>
schemeListeners.delete(fn),
};
const host = {
localStorage: storage,
matchMedia: () => scheme,
document: { documentElement: root, querySelector: () => meta },
getComputedStyle: () => ({
getPropertyValue: () =>
Expand All @@ -39,7 +51,12 @@ function browser(stored: string | null = null) {
meta,
storage,
listeners,
schemeListeners,
values,
systemDark(value: boolean) {
dark = value;
for (const fn of schemeListeners) fn();
},
change(
key: string | null = APPEARANCE_KEY,
storageArea: unknown = storage,
Expand All @@ -49,26 +66,39 @@ function browser(stored: string | null = null) {
};
}

it.each([null, "", "system", "LIGHT", '{"mode":"dark"}', "light", "dark"])(
"bootstrap and service agree for stored %j",
(value) => {
const b = browser(value);
it.each([
[null, false],
["", false],
["LIGHT", false],
['{"mode":"dark"}', false],
["light", false],
["dark", false],
["system", false],
["system", true],
] as const)(
"bootstrap and service agree for stored %j with system dark %j",
(value, systemDark) => {
const b = browser(value, systemDark);
runInNewContext(readFileSync("public/appearance-init.js", "utf8"), {
localStorage: b.storage,
document: b.host.document,
matchMedia: b.host.matchMedia,
});
expect(b.root.dataset.colorMode).toBe(parseColorMode(value));
const mode =
value === "system" && systemDark ? "dark" : parseColorMode(value);
expect(b.root.dataset.colorMode).toBe(mode);
const app = createAppearance(b.host);
expect(app.snapshot()).toEqual({
mode: parseColorMode(value),
preference: parseColorModePreference(value),
mode,
error: null,
fontScale: 1,
fontError: null,
});
expect(b.root.dataset.colorMode).toBe(app.snapshot().mode);
expect(b.meta.setAttribute).toHaveBeenLastCalledWith(
"content",
value === "dark" ? "#11181d" : "#e7f0ef",
mode === "dark" ? "#11181d" : "#e7f0ef",
);
app.dispose();
},
Expand Down Expand Up @@ -140,11 +170,39 @@ it("cross-window updates re-read current storage, ignore other stores, and dispo
expect(app.snapshot().mode).toBe("light");
});

it("System follows computer changes, manual choices override it, and disposal stops updates", () => {
const b = browser();
const app = createAppearance(b.host);
const changed = vi.fn();
app.subscribe(changed);
app.setMode("system");
expect(b.values.get(APPEARANCE_KEY)).toBe("system");
expect(app.snapshot().preference).toBe("system");
expect(app.snapshot().mode).toBe("light");
b.systemDark(true);
expect(app.snapshot().mode).toBe("dark");
expect(b.root.dataset.colorMode).toBe("dark");
expect(b.meta.setAttribute).toHaveBeenLastCalledWith("content", "#11181d");
expect(changed).toHaveBeenCalledTimes(2);
app.setMode("light");
b.systemDark(false);
b.systemDark(true);
expect(app.snapshot().mode).toBe("light");
expect(changed).toHaveBeenCalledTimes(3);
b.values.set(APPEARANCE_KEY, "system");
b.change();
expect(app.snapshot().mode).toBe("dark");
app.dispose();
expect(b.schemeListeners.size).toBe(0);
b.systemDark(false);
expect(app.snapshot().mode).toBe("dark");
});

it("rejects an invalid runtime write without saving it", () => {
const b = browser("dark");
const app = createAppearance(b.host);
// External JS callers do not have TypeScript's union guarantee.
app.setMode("system" as "light");
app.setMode("unknown" as "light");
expect(app.snapshot().mode).toBe("dark");
expect(b.storage.setItem).not.toHaveBeenCalled();
app.dispose();
Expand Down
43 changes: 38 additions & 5 deletions src/shared/theme/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** Host-owned, device-local appearance. Never depends on plugin/relay readiness. */
export type ColorMode = "light" | "dark";
export type ColorModePreference = ColorMode | "system";
export const APPEARANCE_KEY = "buzz-appearance.v1";
export const FONT_SCALE_KEY = "buzz-font-scale.v1";
export const MIN_FONT_SCALE = 0.8;
Expand All @@ -16,8 +17,13 @@ export function parseFontScale(value: unknown): number {
}
export const parseColorMode = (value: unknown): ColorMode =>
value === "dark" ? "dark" : "light";
export const parseColorModePreference = (
value: unknown,
): ColorModePreference =>
value === "system" ? "system" : parseColorMode(value);

export interface AppearanceSnapshot {
readonly preference: ColorModePreference;
readonly mode: ColorMode;
readonly fontScale: number;
readonly fontError: string | null;
Expand All @@ -27,7 +33,15 @@ export interface AppearanceSnapshot {
export function createAppearance(
host: Window | undefined = typeof window === "undefined" ? undefined : window,
) {
const systemScheme = host?.matchMedia?.("(prefers-color-scheme: dark)");
const resolvedMode = (preference: ColorModePreference): ColorMode =>
preference === "system"
? systemScheme?.matches
? "dark"
: "light"
: preference;
let state: AppearanceSnapshot = {
preference: "light",
mode: "light",
error: null,
fontScale: 1,
Expand All @@ -54,9 +68,13 @@ export function createAppearance(
};
const restore = () => {
try {
const preference = parseColorModePreference(
host?.localStorage.getItem(APPEARANCE_KEY),
);
state = {
...state,
mode: parseColorMode(host?.localStorage.getItem(APPEARANCE_KEY)),
preference,
mode: resolvedMode(preference),
error: null,
};
} catch {
Expand All @@ -68,6 +86,13 @@ export function createAppearance(
}
notify();
};
const onSystemSchemeChange = () => {
if (disposed || state.preference !== "system") return;
const mode = resolvedMode("system");
if (mode === state.mode) return;
state = { ...state, mode };
notify();
};
const restoreFont = () => {
try {
state = {
Expand Down Expand Up @@ -104,6 +129,7 @@ export function createAppearance(
restore();
restoreFont();
host?.addEventListener("storage", onStorage);
systemScheme?.addEventListener("change", onSystemSchemeChange);
return {
snapshot: () => state,
subscribe(listener: () => void) {
Expand All @@ -113,17 +139,23 @@ export function createAppearance(
listeners.delete(listener);
};
},
setMode(mode: ColorMode) {
if (disposed || (mode !== "light" && mode !== "dark")) return;
setMode(preference: ColorModePreference) {
if (
disposed ||
(preference !== "light" &&
preference !== "dark" &&
preference !== "system")
)
return;
let error: string | null = null;
try {
if (!host) throw new Error("No browser storage");
host.localStorage.setItem(APPEARANCE_KEY, mode);
host.localStorage.setItem(APPEARANCE_KEY, preference);
} catch {
error =
"This appearance is active, but could not be saved on this device. Try again.";
}
state = { ...state, mode, error };
state = { ...state, preference, mode: resolvedMode(preference), error };
notify();
},
setFontScale(value: number) {
Expand All @@ -145,6 +177,7 @@ export function createAppearance(
dispose() {
disposed = true;
host?.removeEventListener("storage", onStorage);
systemScheme?.removeEventListener("change", onSystemSchemeChange);
listeners.clear();
},
};
Expand Down
29 changes: 29 additions & 0 deletions tests/browser/appearance.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,35 @@ test("Appearance changes and restores both modes, shared keyboard controls, dial
await expect(dark).toBeChecked();
});

test("System appearance follows computer changes and keeps the selected choice after reload", async ({
page,
app,
}, testInfo) => {
await page.emulateMedia({ colorScheme: "light" });
await page.goto(app.origin);
await settings(page);
const system = page.getByRole("radio", { name: "System", exact: true });
await system.check();
await expect(system).toBeChecked();
await expectMode(page, "light");
await page.screenshot({ path: testInfo.outputPath("system-light.png") });
expect(await page.evaluate((key) => localStorage.getItem(key), key)).toBe(
"system",
);
await page.emulateMedia({ colorScheme: "dark" });
await expectMode(page, "dark");
await page.reload();
await expectMode(page, "dark");
await settings(page);
await expect(system).toBeChecked();
await page.screenshot({ path: testInfo.outputPath("system-dark.png") });
await page.emulateMedia({ colorScheme: "light" });
await expectMode(page, "light");
await page.getByRole("radio", { name: "Dark", exact: true }).check();
await page.emulateMedia({ colorScheme: "light" });
await expectMode(page, "dark");
});

test("storage denial is visible and retryable; another window updates a live conversation without remount", async ({
page,
context,
Expand Down
Loading