diff --git a/ui/goose2/.github/workflows/ci.yml b/ui/goose2/.github/workflows/ci.yml new file mode 100644 index 0000000000..77a162fc41 --- /dev/null +++ b/ui/goose2/.github/workflows/ci.yml @@ -0,0 +1,142 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check + - run: pnpm typecheck + + test: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + + desktop: + name: Desktop Build & E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Cache Rust + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }} + + - run: pnpm install --frozen-lockfile + + - name: Build frontend + run: pnpm build + + - name: Check Tauri + run: cd src-tauri && cargo check + + - name: Clippy + run: cd src-tauri && cargo clippy -- -D warnings + + - name: Format check + run: cd src-tauri && cargo fmt --check + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run E2E smoke tests + run: pnpm exec playwright test --project=smoke + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 + + rust-lint: + name: Rust Lint + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: rustfmt, clippy + + - name: Cache Rust + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }} + + - name: Format check + run: cd src-tauri && cargo fmt --check + + - name: Clippy + run: cd src-tauri && cargo clippy -- -D warnings diff --git a/ui/goose2/.gitignore b/ui/goose2/.gitignore new file mode 100644 index 0000000000..852b309e6e --- /dev/null +++ b/ui/goose2/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Rust/Tauri build artifacts +/target/ +src-tauri/target/ + +# Environment files +.env +.env.* +!.env.example + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.*.sw? + +# OS artifacts +.DS_Store +Thumbs.db + +# Scratch / working files +.scratch/ + +# Playwright artifacts +playwright-report/ +test-results/ + +# Testing coverage +coverage/ + +# Logs +*.log + +# Hermit (toolchain manager cache) +.hermit/ diff --git a/ui/goose2/AGENTS.md b/ui/goose2/AGENTS.md new file mode 100644 index 0000000000..a5bf039e6b --- /dev/null +++ b/ui/goose2/AGENTS.md @@ -0,0 +1,112 @@ +# AGENTS.md + +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/`. + +## Architecture & File Structure + +``` +src/ + app/ — App shell, entry point, top-level providers + features/ — Feature modules (tabs, sidebar, settings, status) + / + ui/ — React components + types.ts — Shared types for the feature + shared/ + ui/ — Reusable UI components (button, etc.) + lib/ — Utilities (cn.ts for class merging) + theme/ — Theme provider, appearance settings + styles/ — Global CSS, design tokens + hooks/ — Shared hooks + api/ — API integration + constants/ — Shared constants + context/ — Shared contexts +``` + +## Coding Conventions + +- Use `cn()` from `@/shared/lib/cn` for Tailwind class merging. +- Import paths use the `@/` alias (maps to `./src`). +- Components are controlled where possible (state lifted to parent). +- Use `lucide-react` for icons. +- All ` + + + + + + ); +} diff --git a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx new file mode 100644 index 0000000000..a1e21b685f --- /dev/null +++ b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { HomeScreen } from "./HomeScreen"; + +// Mock the ThemeProvider since HomeScreen doesn't use it directly +// but its children might +describe("HomeScreen", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 2, 29, 14, 30, 0)); // 2:30 PM + }); + + it("renders the clock", () => { + render(); + expect(screen.getByText("2:30")).toBeInTheDocument(); + expect(screen.getByText("PM")).toBeInTheDocument(); + }); + + it("shows afternoon greeting at 2:30 PM", () => { + render(); + expect(screen.getByText("Good afternoon")).toBeInTheDocument(); + }); + + it("renders the chat input placeholder", () => { + render(); + expect(screen.getByText("Ask Goose anything...")).toBeInTheDocument(); + }); + + it("renders the model badge", () => { + render(); + expect(screen.getByText("Claude Sonnet 4")).toBeInTheDocument(); + }); +}); diff --git a/ui/goose2/src/features/home/ui/HomeScreen.tsx b/ui/goose2/src/features/home/ui/HomeScreen.tsx new file mode 100644 index 0000000000..6dd5904b07 --- /dev/null +++ b/ui/goose2/src/features/home/ui/HomeScreen.tsx @@ -0,0 +1,79 @@ +import { useState, useEffect } from "react"; +import { ArrowUp } from "lucide-react"; + +function HomeClock() { + const [time, setTime] = useState(new Date()); + + useEffect(() => { + const interval = setInterval(() => setTime(new Date()), 1000); + return () => clearInterval(interval); + }, []); + + const hours = time + .toLocaleTimeString("en-US", { hour: "numeric", hour12: true }) + .replace(/\s?(AM|PM)$/i, ""); + const minutes = time + .toLocaleTimeString("en-US", { minute: "2-digit" }) + .padStart(2, "0"); + const period = time.getHours() >= 12 ? "PM" : "AM"; + + return ( +
+ + {hours}:{minutes} + + {period} +
+ ); +} + +function getGreeting(hour: number): string { + if (hour < 12) return "Good morning"; + if (hour < 17) return "Good afternoon"; + return "Good evening"; +} + +export function HomeScreen() { + const [hour] = useState(() => new Date().getHours()); + const greeting = getGreeting(hour); + + return ( +
+
+
+ {/* Clock */} + + + {/* Greeting */} +

+ {greeting} +

+ + {/* Chat input */} +
+
+ {/* Textarea placeholder */} +
+ Ask Goose anything... +
+ {/* Bottom bar */} +
+
+ + Claude Sonnet 4 + +
+ +
+
+
+
+
+
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx b/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx new file mode 100644 index 0000000000..54a05cf649 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx @@ -0,0 +1,140 @@ +import { cn } from "@/shared/lib/cn"; +import { useTheme } from "@/shared/theme/ThemeProvider"; +import { Sun, Moon, Monitor, Check } from "lucide-react"; + +const THEME_OPTIONS = [ + { value: "light", icon: Sun, label: "Light" }, + { value: "dark", icon: Moon, label: "Dark" }, + { value: "system", icon: Monitor, label: "System" }, +] as const; + +const ACCENT_COLORS = [ + { name: "Blue", value: "#3b82f6" }, + { name: "Cyan", value: "#06b6d4" }, + { name: "Green", value: "#22c55e" }, + { name: "Orange", value: "#f97316" }, + { name: "Red", value: "#ef4444" }, + { name: "Pink", value: "#ec4899" }, + { name: "Purple", value: "#a855f7" }, + { name: "Indigo", value: "#6366f1" }, +]; + +const DENSITY_OPTIONS = [ + { value: "compact", label: "Compact" }, + { value: "comfortable", label: "Comfortable" }, + { value: "spacious", label: "Spacious" }, +] as const; + +function SettingRow({ + label, + description, + children, +}: { + label: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{label}

+ {description && ( +

+ {description} +

+ )} +
+
{children}
+
+ ); +} + +export function AppearanceSettings() { + const { theme, setTheme, accentColor, setAccentColor, density, setDensity } = + useTheme(); + + return ( +
+

Appearance

+

+ Customize the look and feel of Goose +

+ +
+ + +
+ {THEME_OPTIONS.map((option) => ( + + ))} +
+
+ +
+ + +
+ {ACCENT_COLORS.map((color) => ( + + ))} +
+
+ +
+ + +
+ {DENSITY_OPTIONS.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx new file mode 100644 index 0000000000..66e36c42eb --- /dev/null +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect } from "react"; +import { cn } from "@/shared/lib/cn"; +import { Palette, Settings2, Info, X } from "lucide-react"; +import { AppearanceSettings } from "./AppearanceSettings"; + +const NAV_ITEMS = [ + { id: "appearance", label: "Appearance", icon: Palette }, + { id: "general", label: "General", icon: Settings2 }, + { id: "about", label: "About", icon: Info }, +] as const; + +type SectionId = (typeof NAV_ITEMS)[number]["id"]; + +interface SettingsModalProps { + onClose: () => void; +} + +export function SettingsModal({ onClose }: SettingsModalProps) { + const [activeSection, setActiveSection] = useState("appearance"); + const [isLoaded, setIsLoaded] = useState(false); + const [isTransitioning, setIsTransitioning] = useState(false); + + // Trigger entrance animations after mount + useEffect(() => { + const timer = setTimeout(() => setIsLoaded(true), 50); + return () => clearTimeout(timer); + }, []); + + // Content transition on section change + // biome-ignore lint/correctness/useExhaustiveDependencies: activeSection triggers the transition effect intentionally + useEffect(() => { + setIsTransitioning(true); + const timer = setTimeout(() => setIsTransitioning(false), 150); + return () => clearTimeout(timer); + }, [activeSection]); + + return ( +
{ + if (e.key === "Escape") onClose(); + }} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation on inner container is not a meaningful interaction */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: click handler only prevents backdrop dismiss propagation */} +
e.stopPropagation()} + > + {/* Sidebar */} +
+
+

Settings

+
+ +
+ + {/* Content */} +
+ + +
+
+ {activeSection === "appearance" && } + {activeSection === "general" && ( +
+

General

+

+ General settings will appear here. +

+
+ )} + {activeSection === "about" && ( +
+

About

+

+ About information will appear here. +

+
+ )} +
+
+
+
+
+ ); +} diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx new file mode 100644 index 0000000000..d47f58b4e2 --- /dev/null +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -0,0 +1,90 @@ +import { Plus, MessageCircle, Hash, Settings2 } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; + +interface SidebarProps { + isOpen: boolean; + onSettingsClick?: () => void; +} + +const recentItems = [ + { id: "1", icon: MessageCircle, label: "Debug login flow" }, + { id: "2", icon: Hash, label: "API refactor notes" }, + { id: "3", icon: MessageCircle, label: "Weekend deploy plan" }, + { id: "4", icon: Hash, label: "Design review" }, +]; + +export function Sidebar({ isOpen, onSettingsClick }: SidebarProps) { + return ( + + ); +} diff --git a/ui/goose2/src/features/status/ui/StatusBar.tsx b/ui/goose2/src/features/status/ui/StatusBar.tsx new file mode 100644 index 0000000000..e07f3fe4fd --- /dev/null +++ b/ui/goose2/src/features/status/ui/StatusBar.tsx @@ -0,0 +1,39 @@ +import { cn } from "@/shared/lib/cn"; + +interface StatusBarProps { + modelName?: string; + tokenCount?: number; + status?: "connected" | "disconnected" | "loading"; +} + +const statusColor = { + connected: "bg-green-500", + disconnected: "bg-red-500", + loading: "bg-yellow-500", +} as const; + +export function StatusBar({ + modelName = "Claude Sonnet 4", + tokenCount = 0, + status = "disconnected", +}: StatusBarProps) { + return ( +
+ {modelName} + +
+ {tokenCount.toLocaleString()} tokens +
+
+
+ ); +} diff --git a/ui/goose2/src/features/tabs/types.ts b/ui/goose2/src/features/tabs/types.ts new file mode 100644 index 0000000000..ccf2c6e328 --- /dev/null +++ b/ui/goose2/src/features/tabs/types.ts @@ -0,0 +1,4 @@ +export interface Tab { + id: string; + title: string; +} diff --git a/ui/goose2/src/features/tabs/ui/TabBar.tsx b/ui/goose2/src/features/tabs/ui/TabBar.tsx new file mode 100644 index 0000000000..46b9664f3e --- /dev/null +++ b/ui/goose2/src/features/tabs/ui/TabBar.tsx @@ -0,0 +1,91 @@ +import { Home, PanelLeft, Plus, X } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; +import type { Tab } from "@/features/tabs/types"; + +interface TabBarProps { + tabs: Tab[]; + activeTabId: string | null; + onTabSelect: (id: string) => void; + onTabClose: (id: string) => void; + onNewTab: () => void; + onHomeClick: () => void; + onSidebarToggle: () => void; +} + +export function TabBar({ + tabs, + activeTabId, + onTabSelect, + onTabClose, + onNewTab, + onHomeClick, + onSidebarToggle, +}: TabBarProps) { + return ( +
+ + + + +
+ {tabs.map((tab) => ( + + + ))} +
+ + +
+ ); +} diff --git a/ui/goose2/src/main.tsx b/ui/goose2/src/main.tsx new file mode 100644 index 0000000000..903e192034 --- /dev/null +++ b/ui/goose2/src/main.tsx @@ -0,0 +1,29 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "@/app/App"; +import { ThemeProvider } from "@/shared/theme/ThemeProvider"; +import "@/shared/styles/globals.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); + +ReactDOM.createRoot(root).render( + + + + + + + , +); diff --git a/ui/goose2/src/shared/api/.gitkeep b/ui/goose2/src/shared/api/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/goose2/src/shared/constants/.gitkeep b/ui/goose2/src/shared/constants/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/goose2/src/shared/context/.gitkeep b/ui/goose2/src/shared/context/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/goose2/src/shared/lib/cn.test.ts b/ui/goose2/src/shared/lib/cn.test.ts new file mode 100644 index 0000000000..6eaf69cbe6 --- /dev/null +++ b/ui/goose2/src/shared/lib/cn.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { cn } from "./cn"; + +describe("cn", () => { + it("merges class names", () => { + expect(cn("foo", "bar")).toBe("foo bar"); + }); + + it("handles conditional classes", () => { + expect(cn("base", false && "hidden", "visible")).toBe("base visible"); + }); + + it("resolves Tailwind conflicts", () => { + expect(cn("px-4", "px-2")).toBe("px-2"); + }); + + it("handles empty inputs", () => { + expect(cn()).toBe(""); + }); +}); diff --git a/ui/goose2/src/shared/lib/cn.ts b/ui/goose2/src/shared/lib/cn.ts new file mode 100644 index 0000000000..a5ef193506 --- /dev/null +++ b/ui/goose2/src/shared/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/ui/goose2/src/shared/styles/globals.css b/ui/goose2/src/shared/styles/globals.css new file mode 100644 index 0000000000..21132d3249 --- /dev/null +++ b/ui/goose2/src/shared/styles/globals.css @@ -0,0 +1,199 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Cash Sans font */ +@font-face { + font-family: "Cash Sans"; + src: + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff2/CashSans-Regular.woff2) + format("woff2"), + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff/CashSans-Regular.woff) + format("woff"); + font-weight: 400; + font-style: normal; +} +@font-face { + font-family: "Cash Sans"; + src: + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff2/CashSans-Medium.woff2) + format("woff2"), + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff/CashSans-Medium.woff) + format("woff"); + font-weight: 500; + font-style: normal; +} +@font-face { + font-family: "Cash Sans"; + src: + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff2/CashSans-Bold.woff2) + format("woff2"), + url(https://cash-f.squarecdn.com/static/fonts/cashsans/woff/CashSans-Bold.woff) + format("woff"); + font-weight: 700; + font-style: normal; +} + +@layer base { + :root { + /* Backgrounds */ + --color-background-primary: #ffffff; + --color-background-secondary: #f4f6f7; + --color-background-tertiary: #e3e6ea; + --color-background-inverse: #000000; + --color-background-ghost: transparent; + --color-background-info: #5c98f9; + --color-background-danger: #f94b4b; + --color-background-success: #91cb80; + --color-background-warning: #fbcd44; + --color-background-disabled: #e3e6ea; + + /* Text */ + --color-text-primary: #3f434b; + --color-text-secondary: #878787; + --color-text-tertiary: #a7b0b9; + --color-text-inverse: #ffffff; + --color-text-ghost: #878787; + --color-text-info: #5c98f9; + --color-text-danger: #f94b4b; + --color-text-success: #91cb80; + --color-text-warning: #fbcd44; + --color-text-disabled: #cbd1d6; + + /* Borders */ + --color-border-primary: #e3e6ea; + --color-border-secondary: #e3e6ea; + --color-border-tertiary: #cbd1d6; + --color-border-inverse: #000000; + --color-border-ghost: transparent; + --color-border-info: #5c98f9; + --color-border-danger: #f94b4b; + --color-border-success: #91cb80; + --color-border-warning: #fbcd44; + --color-border-disabled: #e3e6ea; + + /* Rings */ + --color-ring-primary: #e3e6ea; + --color-ring-secondary: #cbd1d6; + --color-ring-inverse: #ffffff; + --color-ring-info: #5c98f9; + --color-ring-danger: #f94b4b; + --color-ring-success: #91cb80; + --color-ring-warning: #fbcd44; + + /* Shadows */ + --shadow-hairline: 0 0 0 1px rgba(0, 0, 0, 0.05); + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + + /* Border radius */ + --radius-xs: 2px; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + + --color-accent: #3b82f6; + --color-accent-foreground: #ffffff; + --density-spacing: 1; + } + + .dark { + /* Backgrounds */ + --color-background-primary: #22252a; + --color-background-secondary: #3f434b; + --color-background-tertiary: #474e57; + --color-background-inverse: #cbd1d6; + --color-background-ghost: transparent; + --color-background-info: #7cacff; + --color-background-danger: #ff6b6b; + --color-background-success: #a3d795; + --color-background-warning: #ffd966; + --color-background-disabled: #474e57; + + /* Text */ + --color-text-primary: #ffffff; + --color-text-secondary: #878787; + --color-text-tertiary: #606c7a; + --color-text-inverse: #000000; + --color-text-ghost: #878787; + --color-text-info: #7cacff; + --color-text-danger: #ff6b6b; + --color-text-success: #a3d795; + --color-text-warning: #ffd966; + --color-text-disabled: #525b68; + + /* Borders */ + --color-border-primary: #3f434b; + --color-border-secondary: #525b68; + --color-border-tertiary: #474e57; + --color-border-inverse: #ffffff; + --color-border-ghost: transparent; + --color-border-info: #7cacff; + --color-border-danger: #ff6b6b; + --color-border-success: #a3d795; + --color-border-warning: #ffd966; + --color-border-disabled: #3f434b; + + /* Rings */ + --color-ring-primary: #525b68; + --color-ring-secondary: #474e57; + --color-ring-inverse: #000000; + --color-ring-info: #7cacff; + --color-ring-danger: #ff6b6b; + --color-ring-success: #a3d795; + --color-ring-warning: #ffd966; + + /* Shadows */ + --shadow-hairline: 0 0 0 1px rgba(0, 0, 0, 0.2); + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.2); + --shadow-md: + 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2); + --shadow-lg: + 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2); + + --color-accent: #3b82f6; + --color-accent-foreground: #ffffff; + --density-spacing: 1; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground font-sans antialiased; + } +} + +/* Density-aware spacing utilities */ +.gap-density { + gap: calc(0.5rem * var(--density-spacing)); +} +.p-density { + padding: calc(0.5rem * var(--density-spacing)); +} +.py-density { + padding-top: calc(0.5rem * var(--density-spacing)); + padding-bottom: calc(0.5rem * var(--density-spacing)); +} +.px-density { + padding-left: calc(0.5rem * var(--density-spacing)); + padding-right: calc(0.5rem * var(--density-spacing)); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + /* biome-ignore lint/complexity/noImportantStyles: required to override all animations for reduced-motion accessibility */ + animation-duration: 0.01ms !important; + /* biome-ignore lint/complexity/noImportantStyles: required to override all transitions for reduced-motion accessibility */ + transition-duration: 0.01ms !important; + } +} diff --git a/ui/goose2/src/shared/theme/ThemeProvider.test.tsx b/ui/goose2/src/shared/theme/ThemeProvider.test.tsx new file mode 100644 index 0000000000..120f468c38 --- /dev/null +++ b/ui/goose2/src/shared/theme/ThemeProvider.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, beforeEach } from "vitest"; +import { ThemeProvider, useTheme } from "./ThemeProvider"; + +function ThemeConsumer() { + const { theme, setTheme, accentColor, density } = useTheme(); + return ( +
+ {theme} + {accentColor} + {density} + + +
+ ); +} + +describe("ThemeProvider", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("light", "dark"); + }); + + it("provides default theme as system", () => { + render( + + + , + ); + expect(screen.getByTestId("theme")).toHaveTextContent("system"); + }); + + it("switches to dark theme", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await user.click(screen.getByText("Set Dark")); + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + + it("persists theme to localStorage", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await user.click(screen.getByText("Set Light")); + expect(localStorage.getItem("goose-theme")).toBe("light"); + }); + + it("provides default accent color", () => { + render( + + + , + ); + expect(screen.getByTestId("accent")).toHaveTextContent("#3b82f6"); + }); + + it("provides default density", () => { + render( + + + , + ); + expect(screen.getByTestId("density")).toHaveTextContent("comfortable"); + }); +}); diff --git a/ui/goose2/src/shared/theme/ThemeProvider.tsx b/ui/goose2/src/shared/theme/ThemeProvider.tsx new file mode 100644 index 0000000000..0958d92143 --- /dev/null +++ b/ui/goose2/src/shared/theme/ThemeProvider.tsx @@ -0,0 +1,144 @@ +import * as React from "react"; + +type ThemePreference = "light" | "dark" | "system"; +type ResolvedTheme = "light" | "dark"; +type Density = "compact" | "comfortable" | "spacious"; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: ThemePreference; +}; + +type ThemeProviderState = { + theme: ThemePreference; + resolvedTheme: ResolvedTheme; + setTheme: (theme: ThemePreference) => void; + accentColor: string; + setAccentColor: (color: string) => void; + density: Density; + setDensity: (d: Density) => void; +}; + +const ThemeProviderContext = React.createContext< + ThemeProviderState | undefined +>(undefined); + +function resolveTheme(preference: ThemePreference): ResolvedTheme { + if (preference === "system") { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + return preference; +} + +export function ThemeProvider({ + children, + defaultTheme = "system", +}: ThemeProviderProps) { + const [theme, setThemeState] = React.useState(() => { + const stored = localStorage.getItem( + "goose-theme", + ) as ThemePreference | null; + return stored ?? defaultTheme; + }); + + const [resolvedTheme, setResolvedTheme] = React.useState(() => + resolveTheme(theme), + ); + + const [accentColor, setAccentColorState] = React.useState(() => { + return localStorage.getItem("goose-accent-color") ?? "#3b82f6"; + }); + + const [density, setDensityState] = React.useState(() => { + const stored = localStorage.getItem("goose-density") as Density | null; + return stored ?? "comfortable"; + }); + + 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 setDensity = React.useCallback((d: Density) => { + localStorage.setItem("goose-density", d); + setDensityState(d); + }, []); + + React.useEffect(() => { + const root = window.document.documentElement; + const resolved = resolveTheme(theme); + setResolvedTheme(resolved); + + root.classList.remove("light", "dark"); + root.classList.add(resolved); + root.style.colorScheme = resolved; + + if (theme === "system") { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = () => { + const updated = mq.matches ? "dark" : "light"; + setResolvedTheme(updated); + root.classList.remove("light", "dark"); + root.classList.add(updated); + root.style.colorScheme = updated; + }; + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + } + }, [theme]); + + React.useEffect(() => { + const root = window.document.documentElement; + root.style.setProperty("--color-accent", accentColor); + root.style.setProperty("--color-accent-foreground", "#ffffff"); + + const spacingScale: Record = { + compact: "0.75", + comfortable: "1", + spacious: "1.25", + }; + root.style.setProperty("--density-spacing", spacingScale[density]); + }, [accentColor, density]); + + const value = React.useMemo( + () => ({ + theme, + resolvedTheme, + setTheme, + accentColor, + setAccentColor, + density, + setDensity, + }), + [ + theme, + resolvedTheme, + setTheme, + accentColor, + setAccentColor, + density, + setDensity, + ], + ); + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = React.useContext(ThemeProviderContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/ui/goose2/src/shared/ui/button.tsx b/ui/goose2/src/shared/ui/button.tsx new file mode 100644 index 0000000000..9ad0c101fa --- /dev/null +++ b/ui/goose2/src/shared/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/shared/lib/cn"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-background-inverse text-foreground-inverse shadow hover:bg-background-inverse/90", + destructive: + "bg-background-danger text-foreground-inverse shadow-sm hover:bg-background-danger/90", + outline: + "border border-border bg-background shadow-sm hover:bg-background-secondary hover:text-foreground", + secondary: + "bg-background-secondary text-foreground shadow-sm hover:bg-background-secondary/80", + ghost: "hover:bg-background-secondary hover:text-foreground", + link: "text-foreground underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export type ButtonProps = React.ButtonHTMLAttributes & + VariantProps & { + asChild?: boolean; + }; + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/ui/goose2/src/test/setup.ts b/ui/goose2/src/test/setup.ts new file mode 100644 index 0000000000..0ef1ad2563 --- /dev/null +++ b/ui/goose2/src/test/setup.ts @@ -0,0 +1,16 @@ +import "@testing-library/jest-dom/vitest"; + +// Mock matchMedia for jsdom (not available by default) +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); diff --git a/ui/goose2/src/vite-env.d.ts b/ui/goose2/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/ui/goose2/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/goose2/tailwind.config.js b/ui/goose2/tailwind.config.js new file mode 100644 index 0000000000..cc2c34b6df --- /dev/null +++ b/ui/goose2/tailwind.config.js @@ -0,0 +1,93 @@ +import tailwindcssAnimate from "tailwindcss-animate"; + +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + fontFamily: { + sans: ["'Cash Sans'", "sans-serif"], + mono: ["monospace"], + }, + borderRadius: { + xs: "var(--radius-xs)", + sm: "var(--radius-sm)", + md: "var(--radius-md)", + lg: "var(--radius-lg)", + xl: "var(--radius-xl)", + full: "var(--radius-full)", + }, + boxShadow: { + hairline: "var(--shadow-hairline)", + sm: "var(--shadow-sm)", + md: "var(--shadow-md)", + lg: "var(--shadow-lg)", + }, + colors: { + background: { + DEFAULT: "var(--color-background-primary)", + primary: "var(--color-background-primary)", + secondary: "var(--color-background-secondary)", + tertiary: "var(--color-background-tertiary)", + inverse: "var(--color-background-inverse)", + ghost: "var(--color-background-ghost)", + info: "var(--color-background-info)", + danger: "var(--color-background-danger)", + success: "var(--color-background-success)", + warning: "var(--color-background-warning)", + disabled: "var(--color-background-disabled)", + }, + foreground: { + DEFAULT: "var(--color-text-primary)", + primary: "var(--color-text-primary)", + secondary: "var(--color-text-secondary)", + tertiary: "var(--color-text-tertiary)", + inverse: "var(--color-text-inverse)", + ghost: "var(--color-text-ghost)", + info: "var(--color-text-info)", + danger: "var(--color-text-danger)", + success: "var(--color-text-success)", + warning: "var(--color-text-warning)", + disabled: "var(--color-text-disabled)", + }, + border: { + DEFAULT: "var(--color-border-primary)", + primary: "var(--color-border-primary)", + secondary: "var(--color-border-secondary)", + tertiary: "var(--color-border-tertiary)", + inverse: "var(--color-border-inverse)", + ghost: "var(--color-border-ghost)", + info: "var(--color-border-info)", + danger: "var(--color-border-danger)", + success: "var(--color-border-success)", + warning: "var(--color-border-warning)", + disabled: "var(--color-border-disabled)", + }, + ring: { + DEFAULT: "var(--color-ring-primary)", + primary: "var(--color-ring-primary)", + secondary: "var(--color-ring-secondary)", + inverse: "var(--color-ring-inverse)", + info: "var(--color-ring-info)", + danger: "var(--color-ring-danger)", + success: "var(--color-ring-success)", + warning: "var(--color-ring-warning)", + }, + accent: { + DEFAULT: "var(--color-accent)", + foreground: "var(--color-accent-foreground)", + }, + // Semantic status colors for inline use (status dots, badges) + green: { 500: "#91cb80" }, + red: { 500: "#f94b4b" }, + yellow: { 500: "#fbcd44" }, + }, + transitionDuration: { + 400: "400ms", + 600: "600ms", + }, + }, + }, + plugins: [tailwindcssAnimate], +}; diff --git a/ui/goose2/tests/e2e/smoke.spec.ts b/ui/goose2/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..14111fe11b --- /dev/null +++ b/ui/goose2/tests/e2e/smoke.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Smoke tests", () => { + test("app loads and shows home screen", async ({ page }) => { + await page.goto("/"); + + // Wait for the app to render — greeting should appear + await expect( + page.getByText(/Good (morning|afternoon|evening)/), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("home screen shows clock", async ({ page }) => { + await page.goto("/"); + + // Should show AM or PM once the clock renders + await expect(page.getByText(/[AP]M/)).toBeVisible({ timeout: 10_000 }); + }); + + test("home screen shows chat input placeholder", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText("Ask Goose anything...")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("home screen shows model badge", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText("Claude Sonnet 4").first()).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/ui/goose2/tsconfig.json b/ui/goose2/tsconfig.json new file mode 100644 index 0000000000..6715b819a0 --- /dev/null +++ b/ui/goose2/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/ui/goose2/tsconfig.node.json b/ui/goose2/tsconfig.node.json new file mode 100644 index 0000000000..42872c59f5 --- /dev/null +++ b/ui/goose2/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/ui/goose2/vite.config.ts b/ui/goose2/vite.config.ts new file mode 100644 index 0000000000..defd75fde3 --- /dev/null +++ b/ui/goose2/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// @ts-expect-error process is a nodejs global +const host = process.env.TAURI_DEV_HOST; + +export default defineConfig(async () => ({ + plugins: [react()], + resolve: { + alias: { + "@": "/src", + }, + }, + clearScreen: false, + server: { + port: 1520, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1521, + } + : undefined, + watch: { + ignored: ["**/src-tauri/**"], + }, + }, +})); diff --git a/ui/goose2/vitest.config.ts b/ui/goose2/vitest.config.ts new file mode 100644 index 0000000000..6f66312cda --- /dev/null +++ b/ui/goose2/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + css: true, + }, +});