fix: respect goose2 interface density settings (#8953)

This commit is contained in:
Kalvin C
2026-05-01 14:08:48 -07:00
committed by GitHub
parent 9b1780bde5
commit e76640c8c4
5 changed files with 201 additions and 25 deletions
+3 -9
View File
@@ -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";
@@ -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.
}
@@ -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" });
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
View File
@@ -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);
@@ -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,
@@ -12,6 +23,7 @@ function ThemeConsumer() {
setAccentColor,
resetAccentColor,
density,
setDensity,
} = useTheme();
return (
<div>
@@ -37,6 +49,9 @@ function ThemeConsumer() {
<button type="button" onClick={resetAccentColor}>
Reset Accent
</button>
<button type="button" onClick={() => setDensity("spacious")}>
Set Spacious
</button>
</div>
);
}
@@ -45,6 +60,7 @@ describe("ThemeProvider", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("light", "dark");
document.documentElement.removeAttribute("data-density");
document.documentElement.removeAttribute("style");
});
@@ -195,6 +211,23 @@ 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>
@@ -202,5 +235,92 @@ describe("ThemeProvider", () => {
</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));");
});
});
+26 -16
View File
@@ -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;
@@ -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
@@ -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>(() =>
@@ -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(() => {
@@ -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(