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
12 changes: 3 additions & 9 deletions ui/goose2/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,6 @@
getDefaultAccent(resolvedTheme);
const foreground = getContrastColor(accent);
const density = localStorage.getItem("goose-density") || "comfortable";
const spacingScale = {
compact: "0.75",
comfortable: "1",
spacious: "1.25",
};

root.classList.add(resolvedTheme === "dark" ? "dark" : "light");
root.style.colorScheme = resolvedTheme === "dark" ? "dark" : "light";
Expand All @@ -75,10 +70,9 @@
root.style.setProperty("--color-brand", accent);
root.style.setProperty("--color-brand-foreground", foreground);
root.style.accentColor = accent;
root.style.setProperty(
"--density-spacing",
spacingScale[density] || spacingScale.comfortable,
);
if (density === "compact" || density === "spacious") {
root.dataset.density = density;
}
} catch {
// ThemeProvider applies the canonical theme state after React mounts.
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";

import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import { renderWithProviders } from "@/test/render";
import { AppearanceSettings } from "../AppearanceSettings";

describe("AppearanceSettings", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute("data-density");
document.documentElement.style.removeProperty("--density-spacing");
document.documentElement.style.removeProperty("--spacing");
});

it("updates interface density from the appearance controls", async () => {
const user = userEvent.setup();

renderWithProviders(
<ThemeProvider>
<AppearanceSettings />
</ThemeProvider>,
);

const compact = screen.getByRole("radio", { name: "Compact" });
Comment thread
kalvinnchau marked this conversation as resolved.

await user.click(compact);

await waitFor(() => {
expect(localStorage.getItem("goose-density")).toBe("compact");
expect(document.documentElement.dataset.density).toBe("compact");
expect(
document.documentElement.style.getPropertyValue("--density-spacing"),
).toBe("");
expect(document.documentElement.style.getPropertyValue("--spacing")).toBe(
"",
);
});
expect(compact).toHaveAttribute("data-state", "on");
});
});
10 changes: 10 additions & 0 deletions ui/goose2/src/shared/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@
--density-spacing: 1;
}

[data-density="compact"] {
--density-spacing: 0.75;
--spacing: 0.1875rem;
}

[data-density="spacious"] {
--density-spacing: 1.25;
--spacing: 0.3125rem;
}

.dark {
/* theming accents */
--brand: var(--color-white);
Expand Down
120 changes: 120 additions & 0 deletions ui/goose2/src/shared/theme/ThemeProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it, expect, beforeEach } from "vitest";
import { ThemeProvider, useTheme } from "./ThemeProvider";

const testDirname = dirname(fileURLToPath(import.meta.url));

function rootCssVariable(name: string) {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
}

function ThemeConsumer() {
const {
theme,
Expand All @@ -12,6 +23,7 @@ function ThemeConsumer() {
setAccentColor,
resetAccentColor,
density,
setDensity,
} = useTheme();
return (
<div>
Expand All @@ -37,6 +49,9 @@ function ThemeConsumer() {
<button type="button" onClick={resetAccentColor}>
Reset Accent
</button>
<button type="button" onClick={() => setDensity("spacious")}>
Set Spacious
</button>
</div>
);
}
Expand All @@ -45,6 +60,7 @@ describe("ThemeProvider", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("light", "dark");
document.documentElement.removeAttribute("data-density");
document.documentElement.removeAttribute("style");
});

Expand Down Expand Up @@ -195,12 +211,116 @@ describe("ThemeProvider", () => {
).toBe("#000000");
});

it("falls back to default accent color when storage is invalid", () => {
localStorage.setItem("goose-accent-color", "blue");

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);

expect(screen.getByTestId("accent")).toHaveTextContent("#1a1a1a");
expect(screen.getByTestId("accent-preference")).toHaveTextContent(
"default",
);
expect(rootCssVariable("--color-brand")).toBe("#1a1a1a");
expect(rootCssVariable("--color-brand-foreground")).toBe("#ffffff");
});

it("provides default density", () => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId("density")).toHaveTextContent("comfortable");
expect(document.documentElement).not.toHaveAttribute("data-density");
expect(
document.documentElement.style.getPropertyValue("--density-spacing"),
).toBe("");
expect(document.documentElement.style.getPropertyValue("--spacing")).toBe(
"",
);
});

it("falls back to default theme when storage is invalid", () => {
localStorage.setItem("goose-theme", "sepia");

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);

expect(screen.getByTestId("theme")).toHaveTextContent("system");
});

it("reads persisted density", () => {
localStorage.setItem("goose-density", "compact");

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);

expect(screen.getByTestId("density")).toHaveTextContent("compact");
expect(document.documentElement.dataset.density).toBe("compact");
expect(
document.documentElement.style.getPropertyValue("--density-spacing"),
).toBe("");
expect(document.documentElement.style.getPropertyValue("--spacing")).toBe(
"",
);
});

it("persists density and updates spacing tokens", async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);

await user.click(screen.getByText("Set Spacious"));

expect(screen.getByTestId("density")).toHaveTextContent("spacious");
expect(localStorage.getItem("goose-density")).toBe("spacious");
expect(document.documentElement.dataset.density).toBe("spacious");
expect(
document.documentElement.style.getPropertyValue("--density-spacing"),
).toBe("");
expect(document.documentElement.style.getPropertyValue("--spacing")).toBe(
"",
);
});

it("falls back to comfortable density when storage is invalid", () => {
localStorage.setItem("goose-density", "tiny");

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);

expect(screen.getByTestId("density")).toHaveTextContent("comfortable");
expect(document.documentElement).not.toHaveAttribute("data-density");
});

it("keeps density spacing values in CSS", () => {
const css = readFileSync(
resolve(testDirname, "../styles/globals.css"),
"utf8",
);

expect(css).toContain('[data-density="compact"]');
expect(css).toContain("--density-spacing: 0.75;");
expect(css).toContain("--spacing: 0.1875rem;");
expect(css).toContain('[data-density="spacious"]');
expect(css).toContain("--density-spacing: 1.25;");
expect(css).toContain("--spacing: 0.3125rem;");
expect(css).toContain("padding: calc(0.5rem * var(--density-spacing));");
});
});
42 changes: 26 additions & 16 deletions ui/goose2/src/shared/theme/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ type ThemePreference = "light" | "dark" | "system";
type ResolvedTheme = "light" | "dark";
type Density = "compact" | "comfortable" | "spacious";

const THEME_PREFERENCES = ["light", "dark", "system"] as const;
const DENSITIES = ["compact", "comfortable", "spacious"] as const;

type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: ThemePreference;
Expand All @@ -29,6 +32,14 @@ const DEFAULT_ACCENT_COLOR_PREFERENCE = "default";
const DEFAULT_LIGHT_ACCENT_COLOR = "#1a1a1a";
const DEFAULT_DARK_ACCENT_COLOR = "#ffffff";

function isDensity(value: string | null): value is Density {
return DENSITIES.includes(value as Density);
}

function isThemePreference(value: string | null): value is ThemePreference {
return THEME_PREFERENCES.includes(value as ThemePreference);
}

function resolveTheme(preference: ThemePreference): ResolvedTheme {
if (preference === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
Expand Down Expand Up @@ -93,15 +104,21 @@ function applyAccentColor(root: HTMLElement, color: string) {
root.style.accentColor = color;
}

function applyDensityAttribute(root: HTMLElement, density: Density) {
if (density === "comfortable") {
root.removeAttribute("data-density");
} else {
root.dataset.density = density;
}
}

export function ThemeProvider({
children,
defaultTheme = "system",
}: ThemeProviderProps) {
const [theme, setThemeState] = React.useState<ThemePreference>(() => {
const stored = localStorage.getItem(
"goose-theme",
) as ThemePreference | null;
return stored ?? defaultTheme;
const stored = localStorage.getItem("goose-theme");
return isThemePreference(stored) ? stored : defaultTheme;
});

const [resolvedTheme, setResolvedTheme] = React.useState<ResolvedTheme>(() =>
Expand All @@ -117,8 +134,8 @@ export function ThemeProvider({
});

const [density, setDensityState] = React.useState<Density>(() => {
const stored = localStorage.getItem("goose-density") as Density | null;
return stored ?? "comfortable";
const stored = localStorage.getItem("goose-density");
return isDensity(stored) ? stored : "comfortable";
});

const accentColor = React.useMemo(() => {
Expand Down Expand Up @@ -178,18 +195,11 @@ export function ThemeProvider({
}, [theme]);

React.useEffect(() => {
const root = window.document.documentElement;
applyAccentColor(root, accentColor);
applyAccentColor(window.document.documentElement, accentColor);
}, [accentColor]);

React.useEffect(() => {
const root = window.document.documentElement;
const spacingScale: Record<Density, string> = {
compact: "0.75",
comfortable: "1",
spacious: "1.25",
};
root.style.setProperty("--density-spacing", spacingScale[density]);
React.useLayoutEffect(() => {
applyDensityAttribute(window.document.documentElement, density);
}, [density]);

const value = React.useMemo(
Expand Down
Loading