fix: make goose2 respect accent color (#8952)
This commit is contained in:
+2
-2
@@ -4,7 +4,7 @@ Guidelines for AI agents (and developers) working on this codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Goose2 is a Tauri 2 + React 19 desktop app. It uses TypeScript strict mode, Vite, and Tailwind CSS 3. The codebase follows a feature-sliced architecture organized under `src/app/`, `src/features/`, and `src/shared/`.
|
||||
Goose2 is a Tauri 2 + React 19 desktop app. It uses TypeScript strict mode, Vite, and Tailwind CSS 4. The codebase follows a feature-sliced architecture organized under `src/app/`, `src/features/`, and `src/shared/`.
|
||||
|
||||
## First Steps
|
||||
|
||||
@@ -99,7 +99,7 @@ ThemeProvider manages three axes:
|
||||
| Axis | Values | Persistence | Mechanism |
|
||||
|--------------|---------------------------------|-----------------|----------------------------------------------|
|
||||
| Theme mode | `light`, `dark`, `system` | localStorage | `.dark` class on `<html>` |
|
||||
| Accent color | Any hex value | localStorage | `--color-accent` CSS variable |
|
||||
| Accent color | Hex color | localStorage | `--brand` / `--color-brand` CSS variables |
|
||||
| Density | `compact`, `comfortable`, `spacious` | localStorage | `--density-spacing` CSS variable (0.75/1/1.25) |
|
||||
|
||||
- CSS variables are defined in `globals.css` with light/dark variants.
|
||||
|
||||
@@ -4,6 +4,86 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script>
|
||||
(() => {
|
||||
const getDefaultAccent = (theme) =>
|
||||
theme === "dark" ? "#ffffff" : "#1a1a1a";
|
||||
|
||||
const normalizeHexColor = (color) => {
|
||||
const value = color?.trim();
|
||||
if (!value || value === "default") 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;
|
||||
};
|
||||
|
||||
const getRelativeLuminance = (hexColor) => {
|
||||
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];
|
||||
};
|
||||
|
||||
const getContrastColor = (hexColor) => {
|
||||
const luminance = getRelativeLuminance(hexColor);
|
||||
const blackContrast = (luminance + 0.05) / 0.05;
|
||||
const whiteContrast = 1.05 / (luminance + 0.05);
|
||||
return blackContrast >= whiteContrast ? "#000000" : "#ffffff";
|
||||
};
|
||||
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
const storedTheme = localStorage.getItem("goose-theme") || "system";
|
||||
const resolvedTheme =
|
||||
storedTheme === "system"
|
||||
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light"
|
||||
: storedTheme;
|
||||
const accent =
|
||||
normalizeHexColor(localStorage.getItem("goose-accent-color")) ||
|
||||
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";
|
||||
root.style.setProperty("--brand", accent);
|
||||
root.style.setProperty("--brand-foreground", foreground);
|
||||
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,
|
||||
);
|
||||
} catch {
|
||||
// ThemeProvider applies the canonical theme state after React mounts.
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<title>Goose</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -192,8 +192,8 @@ export function AvatarDropZone({
|
||||
className={cn(
|
||||
"size-16 overflow-hidden border-2 bg-muted shadow-sm",
|
||||
isDragOver
|
||||
? "scale-105 border-accent bg-accent/15 shadow-md ring-4 ring-accent/20"
|
||||
: "border-border hover:border-border hover:bg-accent",
|
||||
? "scale-105 border-brand bg-brand/10 shadow-md ring-4 ring-brand/20"
|
||||
: "border-border hover:border-brand/50 hover:bg-brand/10",
|
||||
disabled && "opacity-70 cursor-not-allowed",
|
||||
isUploading && "animate-pulse",
|
||||
)}
|
||||
|
||||
@@ -397,7 +397,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
return (
|
||||
<div className="mt-3 space-y-2 border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner className="size-3.5 text-accent" />
|
||||
<Spinner className="size-3.5 text-brand" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium">{phaseLabel}</span>
|
||||
{stepInfo && (
|
||||
@@ -420,7 +420,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border bg-background p-3 transition-colors",
|
||||
isActive && "border-accent/50",
|
||||
isActive && "border-brand/50 bg-brand/10",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -451,7 +451,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
|
||||
setupError
|
||||
? "bg-danger"
|
||||
: isActive
|
||||
? "bg-accent animate-pulse"
|
||||
? "bg-brand animate-pulse"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,6 @@ const ACCENT_COLORS = [
|
||||
{ name: "red", value: "#ef4444" },
|
||||
{ name: "pink", value: "#ec4899" },
|
||||
{ name: "purple", value: "#a855f7" },
|
||||
{ name: "indigo", value: "#6366f1" },
|
||||
];
|
||||
|
||||
const DENSITY_OPTIONS = [
|
||||
@@ -53,8 +52,15 @@ function SettingRow({
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { theme, setTheme, accentColor, setAccentColor, density, setDensity } =
|
||||
useTheme();
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
accentColorPreference,
|
||||
resetAccentColor,
|
||||
setAccentColor,
|
||||
density,
|
||||
setDensity,
|
||||
} = useTheme();
|
||||
|
||||
return (
|
||||
<SettingsPage title={t("appearance.title")}>
|
||||
@@ -87,21 +93,38 @@ export function AppearanceSettings() {
|
||||
label={t("appearance.accent.label")}
|
||||
description={t("appearance.accent.description")}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="flex max-w-36 flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
title={t("appearance.accent.colors.default")}
|
||||
aria-label={t("appearance.accent.colors.default")}
|
||||
onClick={resetAccentColor}
|
||||
className={cn(
|
||||
"relative flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-border transition-transform hover:scale-110",
|
||||
accentColorPreference === "default" &&
|
||||
"ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
>
|
||||
<span className="absolute inset-0 bg-[linear-gradient(135deg,#1a1a1a_0_50%,#ffffff_50%_100%)]" />
|
||||
{accentColorPreference === "default" && (
|
||||
<Check className="relative h-4 w-4 rounded-full bg-background p-0.5 text-foreground shadow-sm" />
|
||||
)}
|
||||
</button>
|
||||
{ACCENT_COLORS.map((color) => (
|
||||
<button
|
||||
type="button"
|
||||
key={color.value}
|
||||
title={t(`appearance.accent.colors.${color.name}`)}
|
||||
aria-label={t(`appearance.accent.colors.${color.name}`)}
|
||||
onClick={() => setAccentColor(color.value)}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full transition-transform hover:scale-110",
|
||||
accentColor === color.value &&
|
||||
accentColorPreference === color.value &&
|
||||
"ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
>
|
||||
{accentColor === color.value && (
|
||||
{accentColorPreference === color.value && (
|
||||
<Check className="h-3.5 w-3.5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function InventorySyncMessage({
|
||||
role="status"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<IconLoader2 className="size-3 animate-spin text-accent" />
|
||||
<IconLoader2 className="size-3 animate-spin text-brand" />
|
||||
<span>{t("providers.loadingModels")}</span>
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -377,7 +377,7 @@ export function ModelProviderRow({
|
||||
)}
|
||||
{authenticating ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Spinner className="size-3.5 text-accent" />
|
||||
<Spinner className="size-3.5 text-brand" />
|
||||
<span>{t("providers.waitingForSignIn")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -486,10 +486,10 @@ export function ModelProviderRow({
|
||||
<IconCheck className="size-4 flex-shrink-0 text-success" />
|
||||
) : null}
|
||||
{inventorySyncing ? (
|
||||
<Spinner className="size-3.5 flex-shrink-0 text-accent" />
|
||||
<Spinner className="size-3.5 flex-shrink-0 text-brand" />
|
||||
) : null}
|
||||
{!isConnected && authenticating ? (
|
||||
<Spinner className="size-3.5 flex-shrink-0 text-accent" />
|
||||
<Spinner className="size-3.5 flex-shrink-0 text-brand" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -349,7 +349,7 @@ export function ProvidersSettings() {
|
||||
</h4>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Spinner className="size-3 text-accent" />
|
||||
<Spinner className="size-3 text-brand" />
|
||||
{t("providers.models.checkingStatus")}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -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