fix: make goose2 respect accent color (#8952)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user