diff --git a/ui/goose2/AGENTS.md b/ui/goose2/AGENTS.md index 481f6e3a..39bf3c9b 100644 --- a/ui/goose2/AGENTS.md +++ b/ui/goose2/AGENTS.md @@ -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 `` | -| 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. diff --git a/ui/goose2/index.html b/ui/goose2/index.html index 64029d2d..de8ca95e 100644 --- a/ui/goose2/index.html +++ b/ui/goose2/index.html @@ -4,6 +4,86 @@ + Goose diff --git a/ui/goose2/src/features/agents/ui/AvatarDropZone.tsx b/ui/goose2/src/features/agents/ui/AvatarDropZone.tsx index 249759a8..fc0544d1 100644 --- a/ui/goose2/src/features/agents/ui/AvatarDropZone.tsx +++ b/ui/goose2/src/features/agents/ui/AvatarDropZone.tsx @@ -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", )} diff --git a/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx b/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx index cc093dc4..f1da3113 100644 --- a/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx +++ b/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx @@ -397,7 +397,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { return (
- +
{phaseLabel} {stepInfo && ( @@ -420,7 +420,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
@@ -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", )} /> diff --git a/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx b/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx index 7f6a7ad9..3d5a0cc3 100644 --- a/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx +++ b/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx @@ -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 ( @@ -87,21 +93,38 @@ export function AppearanceSettings() { label={t("appearance.accent.label")} description={t("appearance.accent.description")} > -
+
+ {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,