+
{ACCENT_COLORS.map((color) => (
diff --git a/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx b/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx
index 05bbdff4..1be0fee7 100644
--- a/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx
+++ b/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx
@@ -33,7 +33,7 @@ export function InventorySyncMessage({
role="status"
className="flex items-center gap-2 text-xs text-muted-foreground"
>
-
+
{t("providers.loadingModels")}
);
diff --git a/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx b/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx
index fc00c0e5..c29aed40 100644
--- a/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx
+++ b/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx
@@ -377,7 +377,7 @@ export function ModelProviderRow({
)}
{authenticating ? (
-
+
{t("providers.waitingForSignIn")}
) : null}
@@ -486,10 +486,10 @@ export function ModelProviderRow({
) : null}
{inventorySyncing ? (
-
+
) : null}
{!isConnected && authenticating ? (
-
+
) : null}
diff --git a/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx b/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx
index f20deaa1..669716f3 100644
--- a/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx
+++ b/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx
@@ -349,7 +349,7 @@ export function ProvidersSettings() {
{loading ? (
-
+
{t("providers.models.checkingStatus")}
) : null}
diff --git a/ui/goose2/src/shared/i18n/locales/en/settings.json b/ui/goose2/src/shared/i18n/locales/en/settings.json
index f8f9ef1d..943c891b 100644
--- a/ui/goose2/src/shared/i18n/locales/en/settings.json
+++ b/ui/goose2/src/shared/i18n/locales/en/settings.json
@@ -20,8 +20,8 @@
"colors": {
"blue": "Blue",
"cyan": "Cyan",
+ "default": "Default",
"green": "Green",
- "indigo": "Indigo",
"orange": "Orange",
"pink": "Pink",
"purple": "Purple",
diff --git a/ui/goose2/src/shared/i18n/locales/es/settings.json b/ui/goose2/src/shared/i18n/locales/es/settings.json
index 249a0d68..27e77cb4 100644
--- a/ui/goose2/src/shared/i18n/locales/es/settings.json
+++ b/ui/goose2/src/shared/i18n/locales/es/settings.json
@@ -20,8 +20,8 @@
"colors": {
"blue": "Azul",
"cyan": "Cian",
+ "default": "Predeterminado",
"green": "Verde",
- "indigo": "Índigo",
"orange": "Naranja",
"pink": "Rosa",
"purple": "Morado",
diff --git a/ui/goose2/src/shared/styles/globals.css b/ui/goose2/src/shared/styles/globals.css
index 100912a2..fa5af0cc 100644
--- a/ui/goose2/src/shared/styles/globals.css
+++ b/ui/goose2/src/shared/styles/globals.css
@@ -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 {
diff --git a/ui/goose2/src/shared/theme/ThemeProvider.test.tsx b/ui/goose2/src/shared/theme/ThemeProvider.test.tsx
index 120f468c..c0d1cbda 100644
--- a/ui/goose2/src/shared/theme/ThemeProvider.test.tsx
+++ b/ui/goose2/src/shared/theme/ThemeProvider.test.tsx
@@ -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 (
{theme}
{accentColor}
+ {accentColorPreference}
{density}
);
}
@@ -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", () => {
,
);
- 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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", () => {
diff --git a/ui/goose2/src/shared/theme/ThemeProvider.tsx b/ui/goose2/src/shared/theme/ThemeProvider.tsx
index 8de5b128..690c71f8 100644
--- a/ui/goose2/src/shared/theme/ThemeProvider.tsx
+++ b/ui/goose2/src/shared/theme/ThemeProvider.tsx
@@ -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
(() => {
- return localStorage.getItem("goose-accent-color") ?? "#3b82f6";
- });
+ const [accentColorPreference, setAccentColorPreference] =
+ React.useState(() => {
+ return (
+ normalizeHexColor(localStorage.getItem("goose-accent-color")) ??
+ DEFAULT_ACCENT_COLOR_PREFERENCE
+ );
+ });
const [density, setDensityState] = React.useState(() => {
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 = {
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,