fix: make goose2 respect accent color (#8952)

This commit is contained in:
Kalvin C
2026-05-01 13:35:06 -07:00
committed by GitHub
parent 936e019f0e
commit 9b1780bde5
13 changed files with 377 additions and 65 deletions
@@ -20,8 +20,8 @@
"colors": {
"blue": "Blue",
"cyan": "Cyan",
"default": "Default",
"green": "Green",
"indigo": "Indigo",
"orange": "Orange",
"pink": "Pink",
"purple": "Purple",
@@ -20,8 +20,8 @@
"colors": {
"blue": "Azul",
"cyan": "Cian",
"default": "Predeterminado",
"green": "Verde",
"indigo": "Índigo",
"orange": "Naranja",
"pink": "Rosa",
"purple": "Morado",
+30 -26
View File
@@ -85,9 +85,11 @@
--radius: 20px;
/* theming accents */
--background-accent: var(--color-gray-900);
--border-accent: var(--color-gray-900);
--text-accent: var(--color-gray-900);
--brand: var(--color-gray-900);
--brand-foreground: #ffffff;
--background-accent: var(--brand);
--border-accent: var(--brand);
--text-accent: var(--brand);
/* Semantic backgrounds */
--background-default: var(--color-white);
@@ -130,7 +132,7 @@
--text-info: var(--color-blue-200);
--text-placeholder: var(--color-gray-400);
--ring: color-mix(in srgb, var(--border-strong) 20%, transparent);
--ring: color-mix(in oklab, var(--brand) 42%, transparent);
/* Alpha variants */
--dark-10: rgba(26, 26, 26, 0.1);
@@ -168,13 +170,13 @@
--card-foreground: var(--text-default);
--popover: var(--background-default);
--popover-foreground: var(--text-default);
--primary: var(--background-accent);
--primary-foreground: var(--text-inverse);
--primary: var(--brand);
--primary-foreground: var(--brand-foreground);
--secondary: var(--background-muted);
--secondary-foreground: var(--text-default);
--muted: var(--background-muted);
--muted-foreground: var(--text-muted);
--accent: var(--background-muted);
--accent: color-mix(in oklab, var(--brand) 12%, var(--background-muted));
--accent-foreground: var(--text-default);
--destructive: var(--background-danger);
--destructive-foreground: #ffffff;
@@ -184,9 +186,9 @@
/* Sidebar */
--sidebar: var(--background-default);
--sidebar-foreground: var(--text-default);
--sidebar-primary: var(--background-accent);
--sidebar-primary-foreground: var(--text-inverse);
--sidebar-accent: var(--background-muted);
--sidebar-primary: var(--brand);
--sidebar-primary-foreground: var(--brand-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--text-default);
--sidebar-border: var(--border-default);
--sidebar-ring: var(--border-default);
@@ -247,16 +249,18 @@
--duration-slow: 0.4s;
/* goose2 custom — brand accent + density */
--color-brand: #3b82f6;
--color-brand-foreground: #ffffff;
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--density-spacing: 1;
}
.dark {
/* theming accents */
--background-accent: var(--color-white);
--border-accent: var(--color-white);
--text-accent: var(--color-white);
--brand: var(--color-white);
--brand-foreground: #000000;
--background-accent: var(--brand);
--border-accent: var(--brand);
--text-accent: var(--brand);
/* shadcn/ui standard tokens — same aliases, dark values cascade via custom vars */
--background: var(--background-default);
@@ -265,13 +269,13 @@
--card-foreground: var(--text-default);
--popover: var(--background-default);
--popover-foreground: var(--text-default);
--primary: var(--background-accent);
--primary-foreground: var(--text-inverse);
--primary: var(--brand);
--primary-foreground: var(--brand-foreground);
--secondary: var(--background-muted);
--secondary-foreground: var(--text-default);
--muted: var(--background-muted);
--muted-foreground: var(--text-muted);
--accent: var(--background-muted);
--accent: color-mix(in oklab, var(--brand) 18%, var(--background-muted));
--accent-foreground: var(--text-default);
--destructive: var(--background-danger);
--destructive-foreground: #ffffff;
@@ -319,7 +323,7 @@
--text-info: var(--color-blue-100);
--text-placeholder: var(--color-gray-600);
--ring: color-mix(in srgb, var(--border-strong) 20%, transparent);
--ring: color-mix(in oklab, var(--brand) 52%, transparent);
/* Alpha variants (dark mode) */
--dark-10: rgba(242, 242, 242, 0.1);
@@ -347,9 +351,9 @@
/* Sidebar */
--sidebar: var(--background-default);
--sidebar-foreground: var(--text-default);
--sidebar-primary: var(--background-accent);
--sidebar-primary-foreground: var(--text-inverse);
--sidebar-accent: var(--background-muted);
--sidebar-primary: var(--brand);
--sidebar-primary-foreground: var(--brand-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--text-default);
--sidebar-border: var(--border-default);
--sidebar-ring: var(--border-default);
@@ -379,8 +383,8 @@
--color-input: var(--input);
/* brand accent */
--color-brand: var(--color-brand);
--color-brand-foreground: var(--color-brand-foreground);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
/* danger (alias for destructive) */
--color-danger: var(--destructive);
@@ -731,8 +735,8 @@ body,
}
::selection {
background: color-mix(in srgb, var(--color-brand) 60%, transparent);
color: #ffffff;
background: color-mix(in oklab, var(--brand) 60%, transparent);
color: var(--brand-foreground);
}
::-webkit-scrollbar {
@@ -4,11 +4,20 @@ import { describe, it, expect, beforeEach } from "vitest";
import { ThemeProvider, useTheme } from "./ThemeProvider";
function ThemeConsumer() {
const { theme, setTheme, accentColor, density } = useTheme();
const {
theme,
setTheme,
accentColor,
accentColorPreference,
setAccentColor,
resetAccentColor,
density,
} = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="accent">{accentColor}</span>
<span data-testid="accent-preference">{accentColorPreference}</span>
<span data-testid="density">{density}</span>
<button type="button" onClick={() => setTheme("dark")}>
Set Dark
@@ -16,6 +25,18 @@ function ThemeConsumer() {
<button type="button" onClick={() => setTheme("light")}>
Set Light
</button>
<button type="button" onClick={() => setAccentColor("#f97316")}>
Set Orange
</button>
<button type="button" onClick={() => setAccentColor("#fff")}>
Set White
</button>
<button type="button" onClick={() => setAccentColor("red")}>
Set Invalid
</button>
<button type="button" onClick={resetAccentColor}>
Reset Accent
</button>
</div>
);
}
@@ -24,6 +45,7 @@ describe("ThemeProvider", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("light", "dark");
document.documentElement.removeAttribute("style");
});
it("provides default theme as system", () => {
@@ -64,7 +86,113 @@ describe("ThemeProvider", () => {
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId("accent")).toHaveTextContent("#3b82f6");
expect(screen.getByTestId("accent")).toHaveTextContent("#1a1a1a");
expect(screen.getByTestId("accent-preference")).toHaveTextContent(
"default",
);
});
it("applies accent color tokens to the document", async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
await user.click(screen.getByText("Set Orange"));
expect(localStorage.getItem("goose-accent-color")).toBe("#f97316");
expect(document.documentElement.style.getPropertyValue("--brand")).toBe(
"#f97316",
);
expect(
document.documentElement.style.getPropertyValue("--brand-foreground"),
).toBe("#000000");
expect(
document.documentElement.style.getPropertyValue("--color-brand"),
).toBe("#f97316");
expect(
document.documentElement.style.getPropertyValue(
"--color-brand-foreground",
),
).toBe("#000000");
expect(document.documentElement.style.accentColor).toBe(
"rgb(249, 115, 22)",
);
});
it("normalizes and validates accent colors", async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
await user.click(screen.getByText("Set White"));
expect(localStorage.getItem("goose-accent-color")).toBe("#ffffff");
expect(
document.documentElement.style.getPropertyValue("--color-brand"),
).toBe("#ffffff");
expect(
document.documentElement.style.getPropertyValue(
"--color-brand-foreground",
),
).toBe("#000000");
await user.click(screen.getByText("Set Invalid"));
expect(localStorage.getItem("goose-accent-color")).toBeNull();
expect(screen.getByTestId("accent-preference")).toHaveTextContent(
"default",
);
expect(
document.documentElement.style.getPropertyValue("--color-brand"),
).toBe("#1a1a1a");
});
it("resets custom accent colors to the theme default", async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
await user.click(screen.getByText("Set Orange"));
await user.click(screen.getByText("Reset Accent"));
expect(localStorage.getItem("goose-accent-color")).toBeNull();
expect(screen.getByTestId("accent")).toHaveTextContent("#1a1a1a");
expect(screen.getByTestId("accent-preference")).toHaveTextContent(
"default",
);
expect(
document.documentElement.style.getPropertyValue("--color-brand"),
).toBe("#1a1a1a");
});
it("updates the default accent color with the theme", async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
await user.click(screen.getByText("Set Dark"));
expect(screen.getByTestId("accent")).toHaveTextContent("#ffffff");
expect(
document.documentElement.style.getPropertyValue("--color-brand"),
).toBe("#ffffff");
expect(
document.documentElement.style.getPropertyValue(
"--color-brand-foreground",
),
).toBe("#000000");
});
it("provides default density", () => {
+94 -17
View File
@@ -14,6 +14,8 @@ type ThemeProviderState = {
resolvedTheme: ResolvedTheme;
setTheme: (theme: ThemePreference) => void;
accentColor: string;
accentColorPreference: string;
resetAccentColor: () => void;
setAccentColor: (color: string) => void;
density: Density;
setDensity: (d: Density) => void;
@@ -23,6 +25,10 @@ const ThemeProviderContext = React.createContext<
ThemeProviderState | undefined
>(undefined);
const DEFAULT_ACCENT_COLOR_PREFERENCE = "default";
const DEFAULT_LIGHT_ACCENT_COLOR = "#1a1a1a";
const DEFAULT_DARK_ACCENT_COLOR = "#ffffff";
function resolveTheme(preference: ThemePreference): ResolvedTheme {
if (preference === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
@@ -32,13 +38,59 @@ function resolveTheme(preference: ThemePreference): ResolvedTheme {
return preference;
}
function getDefaultAccentColor(theme: ResolvedTheme): string {
return theme === "dark"
? DEFAULT_DARK_ACCENT_COLOR
: DEFAULT_LIGHT_ACCENT_COLOR;
}
function normalizeHexColor(color: string | null): string | null {
const value = color?.trim();
if (!value || value === DEFAULT_ACCENT_COLOR_PREFERENCE) return null;
const hex = value.startsWith("#") ? value.slice(1) : value;
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
return `#${hex
.split("")
.map((char) => char + char)
.join("")
.toLowerCase()}`;
}
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
return null;
}
function getRelativeLuminance(hexColor: string): number {
const hex = hexColor.slice(1);
const channels = [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4, 6)].map(
(channel) => {
const value = Number.parseInt(channel, 16) / 255;
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
},
);
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function getContrastColor(hexColor: string): string {
const hex = hexColor.replace("#", "");
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.slice(2, 4), 16);
const b = Number.parseInt(hex.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000000" : "#ffffff";
const luminance = getRelativeLuminance(hexColor);
const blackContrast = (luminance + 0.05) / 0.05;
const whiteContrast = 1.05 / (luminance + 0.05);
return blackContrast >= whiteContrast ? "#000000" : "#ffffff";
}
function applyAccentColor(root: HTMLElement, color: string) {
const foreground = getContrastColor(color);
root.style.setProperty("--brand", color);
root.style.setProperty("--brand-foreground", foreground);
root.style.setProperty("--color-brand", color);
root.style.setProperty("--color-brand-foreground", foreground);
root.style.accentColor = color;
}
export function ThemeProvider({
@@ -56,23 +108,45 @@ export function ThemeProvider({
resolveTheme(theme),
);
const [accentColor, setAccentColorState] = React.useState<string>(() => {
return localStorage.getItem("goose-accent-color") ?? "#3b82f6";
});
const [accentColorPreference, setAccentColorPreference] =
React.useState<string>(() => {
return (
normalizeHexColor(localStorage.getItem("goose-accent-color")) ??
DEFAULT_ACCENT_COLOR_PREFERENCE
);
});
const [density, setDensityState] = React.useState<Density>(() => {
const stored = localStorage.getItem("goose-density") as Density | null;
return stored ?? "comfortable";
});
const accentColor = React.useMemo(() => {
return accentColorPreference === DEFAULT_ACCENT_COLOR_PREFERENCE
? getDefaultAccentColor(resolvedTheme)
: accentColorPreference;
}, [accentColorPreference, resolvedTheme]);
const setTheme = React.useCallback((newTheme: ThemePreference) => {
localStorage.setItem("goose-theme", newTheme);
setThemeState(newTheme);
}, []);
const setAccentColor = React.useCallback((color: string) => {
localStorage.setItem("goose-accent-color", color);
setAccentColorState(color);
const normalizedColor = normalizeHexColor(color);
if (!normalizedColor) {
localStorage.removeItem("goose-accent-color");
setAccentColorPreference(DEFAULT_ACCENT_COLOR_PREFERENCE);
return;
}
localStorage.setItem("goose-accent-color", normalizedColor);
setAccentColorPreference(normalizedColor);
}, []);
const resetAccentColor = React.useCallback(() => {
localStorage.removeItem("goose-accent-color");
setAccentColorPreference(DEFAULT_ACCENT_COLOR_PREFERENCE);
}, []);
const setDensity = React.useCallback((d: Density) => {
@@ -105,19 +179,18 @@ export function ThemeProvider({
React.useEffect(() => {
const root = window.document.documentElement;
root.style.setProperty("--color-brand", accentColor);
root.style.setProperty(
"--color-brand-foreground",
getContrastColor(accentColor),
);
applyAccentColor(root, 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]);
}, [accentColor, density]);
}, [density]);
const value = React.useMemo(
() => ({
@@ -125,6 +198,8 @@ export function ThemeProvider({
resolvedTheme,
setTheme,
accentColor,
accentColorPreference,
resetAccentColor,
setAccentColor,
density,
setDensity,
@@ -134,6 +209,8 @@ export function ThemeProvider({
resolvedTheme,
setTheme,
accentColor,
accentColorPreference,
resetAccentColor,
setAccentColor,
density,
setDensity,