From 70acf22cb8be8936b2d0865f209bed5fe04bd7b9 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 29 Mar 2026 09:34:05 -0700 Subject: [PATCH] feat: scaffold Goose2 desktop app with full UI and testing infrastructure (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: scaffold Tauri + React app with window dragging and app icon Sets up the goose2 desktop app with: - Tauri 2 + React 19 + Vite + Tailwind CSS - Custom titlebar with drag-to-move and double-click maximize/restore - Window state persistence via tauri-plugin-window-state - App icon ported from goose-desktop (cyan/amber yin-yang design) - Theme system with dark/light/system modes - Hermit toolchain (node, pnpm, biome, just, lefthook) Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add app shell layout with tabs, sidebar, and status bar Adds the main application layout ported from goose2.0's visual design: - TabBar with session tabs, home button, and sidebar toggle - Collapsible sidebar with smooth width transition - Status bar with model name, token count, and status indicator - AppShell component wiring everything together Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add settings modal with theme section + quality fixes Settings: - Settings modal with sidebar nav (Appearance, General, About) - Theme selector (Light/Dark/System) wired to ThemeProvider - Accent color picker and density selector (visual placeholders) - Open via Cmd+, or Settings button in sidebar Quality fixes: - Fix stale state bug in tab close handler - Fix unreachable useTheme guard, add OS theme change listener - Improve accessibility (ARIA roles, labels, semantic elements) - Add prefers-reduced-motion support - Extract shared Tab type to features/tabs/types.ts Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove references to nonexistent theme-tokens module The previous commit introduced imports for a theme-tokens module that doesn't exist. Remove those imports and define the needed types locally in ThemeProvider. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: wire up accent colors and density, add AGENTS.md Theming: - ThemeProvider now manages accent color and density (persisted to localStorage) - Accent color applied via --color-accent CSS variable on :root - Density applied via --density-spacing CSS variable (multiplier) - AppearanceSettings uses ThemeProvider context instead of local state - Tailwind config maps accent color variable - Settings modal animations enhanced to match goose2.0 reference - Component classes updated to use semantic token names Documentation: - AGENTS.md captures all project conventions, patterns, and decisions Co-Authored-By: Claude Opus 4.6 (1M context) * fix: replace ambiguous Tailwind arbitrary durations with config values Add duration-400 and duration-600 to Tailwind config to avoid ambiguous class warnings from arbitrary value syntax. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: sidebar minimizes to icon strip instead of hiding The sidebar now shrinks to a 48px icon-only column when toggled, instead of disappearing completely. Icons stay visible with tooltips, labels and section headers are hidden. Smooth width transition. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: improve settings button alignment and styling in sidebar Match goose2.0's footer pattern with proper padding, full-opacity border, flex alignment, and smooth transition between states. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add home screen with clock, greeting, and hidden status bar Home screen shows a live digital clock, time-based greeting, and placeholder chat input. Status bar hides when on the home view (no active tab), matching goose2.0's clean home experience. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: improve home screen to match goose2.0, add chat view Home screen: large mono clock with AM/PM, time-based greeting, goose2.0-style chat input (rounded-2xl, shadow-lg, model badge). Chat view: input at bottom with scrollable messages area. Home vs chat differentiated by active tab state. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: chat view now fills the full available height Remove flex from main element so child components can properly resolve h-full against the block parent. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add testing infrastructure (Vitest, Playwright, CI) Testing: - Vitest for unit/component tests with jsdom + React Testing Library - 13 tests across 3 test files (ThemeProvider, cn utility, HomeScreen) - Playwright for E2E smoke tests (4 tests) - Test setup with matchMedia mock for jsdom Tooling: - Just targets: test, test-watch, test-coverage, test-e2e - Lefthook pre-push now runs unit tests - CI gate includes tests CI/CD: - GitHub Actions workflow with 4 jobs: lint, test, desktop, rust-lint - Playwright artifacts uploaded on failure - Rust caching for faster builds Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve all Biome formatting and lint issues Auto-format all files with Biome. Fix lint issues: - Add type="button" to all interactive buttons - Replace span[role=button] with proper button element - Add dialog role to settings modal - Biome-ignore for standard accessibility !important pattern All checks pass: pnpm check, pnpm test, pnpm build. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: add src-tauri/target/ to .gitignore Prevent Rust/Tauri build artifacts from being committed. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: exclude Tauri generated schemas from Biome formatting * chore: update .gitignore for full project coverage Co-Authored-By: Claude Opus 4.6 (1M context) * fix: pin CI actions to commit SHAs for Semgrep compliance Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve CI failures and improve lefthook auto-formatting - Fix dtolnay/rust-toolchain action SHA (was invalid, causing all Rust CI jobs to fail) - Fix Biome folder ignore pattern (remove trailing /**) - Update lefthook pre-commit to auto-format with biome instead of just checking Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use vite preview directly with explicit host for E2E server The Playwright webServer was using `pnpm preview --port 4173` which invoked vite preview without --host, causing it to bind to localhost (IPv6 ::1) instead of 127.0.0.1. This caused ERR_CONNECTION_REFUSED in CI and locally. Switch to npx vite preview with --host 127.0.0.1 and --strictPort to ensure reliable binding. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: gracefully handle non-Tauri browser environment Check for Tauri runtime before calling window APIs so the app renders correctly in browser-based E2E tests. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: make E2E smoke tests more robust with longer timeouts - Add 10s timeout for element visibility (CI can be slow to render) - Use .first() for model badge to handle multiple matches - Fix strict mode violation for duplicate text matches Co-Authored-By: Claude Opus 4.6 (1M context) * feat: smooth transition for status bar show/hide Status bar now slides and fades in/out with a 300ms transition instead of appearing/disappearing abruptly when switching views. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use dynamic import for Tauri API to prevent browser crash Static import of @tauri-apps/api/window fails at module load time in non-Tauri environments. Use dynamic import() guarded by __TAURI_INTERNALS__ check so the app renders in Playwright. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use python http.server for E2E tests, start with empty tabs Switch Playwright webServer from npx vite preview to python3 http.server for more reliable static file serving in CI. Start app with no tabs open (home screen by default). Co-Authored-By: Claude Opus 4.6 (1M context) * fix: align E2E setup with Sprout's proven configuration - Use url instead of port for webServer readiness check - Remove -b 127.0.0.1 flag from python http.server command - Add explicit cwd to webServer config - Use pnpm exec instead of npx for Playwright in CI Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove unused @tauri-apps/plugin-opener JS dependency The Rust-side tauri-plugin-opener is still registered, but the JS binding package was never imported from frontend code. Removing it keeps the dependency list honest. Dark mode colors already match goose2.0 — no changes needed there. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ui/goose2/.github/workflows/ci.yml | 142 + ui/goose2/.gitignore | 42 + ui/goose2/AGENTS.md | 112 + ui/goose2/app-icon.png | Bin 0 -> 5336 bytes ui/goose2/bin/.biome-2.4.9.pkg | 1 + ui/goose2/bin/.just-1.48.1.pkg | 1 + ui/goose2/bin/.lefthook-2.1.4.pkg | 1 + ui/goose2/bin/.node-24.14.1.pkg | 1 + ui/goose2/bin/.pnpm-10.33.0.pkg | 1 + ui/goose2/bin/README.hermit.md | 7 + ui/goose2/bin/activate-hermit | 21 + ui/goose2/bin/activate-hermit.fish | 24 + ui/goose2/bin/biome | 1 + ui/goose2/bin/corepack | 1 + ui/goose2/bin/hermit | 43 + ui/goose2/bin/hermit.hcl | 2 + ui/goose2/bin/just | 1 + ui/goose2/bin/lefthook | 1 + ui/goose2/bin/node | 1 + ui/goose2/bin/npm | 1 + ui/goose2/bin/npx | 1 + ui/goose2/bin/pnpm | 1 + ui/goose2/biome.json | 38 + ui/goose2/components.json | 19 + ui/goose2/index.html | 13 + ui/goose2/justfile | 89 + ui/goose2/lefthook.yml | 22 + ui/goose2/package.json | 53 + ui/goose2/playwright.config.ts | 33 + ui/goose2/pnpm-lock.yaml | 2843 +++++++++ ui/goose2/postcss.config.js | 6 + ui/goose2/scripts/check-file-sizes.mjs | 65 + ui/goose2/src-tauri/Cargo.lock | 5485 +++++++++++++++++ ui/goose2/src-tauri/Cargo.toml | 20 + ui/goose2/src-tauri/build.rs | 3 + ui/goose2/src-tauri/capabilities/default.json | 16 + .../src-tauri/gen/schemas/acl-manifests.json | 1 + .../src-tauri/gen/schemas/capabilities.json | 1 + .../src-tauri/gen/schemas/desktop-schema.json | 2519 ++++++++ .../src-tauri/gen/schemas/macOS-schema.json | 2519 ++++++++ ui/goose2/src-tauri/icons/128x128.png | Bin 0 -> 3512 bytes ui/goose2/src-tauri/icons/128x128@2x.png | Bin 0 -> 7012 bytes ui/goose2/src-tauri/icons/32x32.png | Bin 0 -> 974 bytes ui/goose2/src-tauri/icons/64x64.png | Bin 0 -> 159 bytes .../src-tauri/icons/Square107x107Logo.png | Bin 0 -> 2863 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 0 -> 3858 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 0 -> 3966 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 0 -> 7737 bytes ui/goose2/src-tauri/icons/Square30x30Logo.png | Bin 0 -> 903 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 0 -> 8591 bytes ui/goose2/src-tauri/icons/Square44x44Logo.png | Bin 0 -> 1299 bytes ui/goose2/src-tauri/icons/Square71x71Logo.png | Bin 0 -> 2011 bytes ui/goose2/src-tauri/icons/Square89x89Logo.png | Bin 0 -> 2468 bytes ui/goose2/src-tauri/icons/StoreLogo.png | Bin 0 -> 1523 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../icons/android/mipmap-hdpi/ic_launcher.png | Bin 0 -> 173 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 373 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 278 bytes .../icons/android/mipmap-mdpi/ic_launcher.png | Bin 0 -> 173 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 238 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 274 bytes .../android/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 282 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 514 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 486 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 426 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 901 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 714 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 563 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 1355 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 902 bytes .../android/values/ic_launcher_background.xml | 4 + ui/goose2/src-tauri/icons/icon.icns | Bin 0 -> 98451 bytes ui/goose2/src-tauri/icons/icon.ico | Bin 0 -> 86642 bytes ui/goose2/src-tauri/icons/icon.png | Bin 0 -> 14183 bytes .../src-tauri/icons/ios/AppIcon-20x20@1x.png | Bin 0 -> 91 bytes .../icons/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 115 bytes .../src-tauri/icons/ios/AppIcon-20x20@2x.png | Bin 0 -> 115 bytes .../src-tauri/icons/ios/AppIcon-20x20@3x.png | Bin 0 -> 145 bytes .../src-tauri/icons/ios/AppIcon-29x29@1x.png | Bin 0 -> 103 bytes .../icons/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 141 bytes .../src-tauri/icons/ios/AppIcon-29x29@2x.png | Bin 0 -> 141 bytes .../src-tauri/icons/ios/AppIcon-29x29@3x.png | Bin 0 -> 194 bytes .../src-tauri/icons/ios/AppIcon-40x40@1x.png | Bin 0 -> 115 bytes .../icons/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 183 bytes .../src-tauri/icons/ios/AppIcon-40x40@2x.png | Bin 0 -> 183 bytes .../src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 0 -> 263 bytes .../src-tauri/icons/ios/AppIcon-512@2x.png | Bin 0 -> 5693 bytes .../src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 0 -> 263 bytes .../src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 0 -> 417 bytes .../src-tauri/icons/ios/AppIcon-76x76@1x.png | Bin 0 -> 178 bytes .../src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 0 -> 351 bytes .../icons/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 386 bytes ui/goose2/src-tauri/src/lib.rs | 14 + ui/goose2/src-tauri/src/main.rs | 6 + ui/goose2/src-tauri/tauri.conf.json | 44 + ui/goose2/src-tauri/tauri.dev.conf.json | 4 + ui/goose2/src/app/App.tsx | 18 + ui/goose2/src/app/AppShell.tsx | 78 + ui/goose2/src/env.d.ts | 7 + ui/goose2/src/features/chat/ui/ChatView.tsx | 40 + .../src/features/home/ui/HomeScreen.test.tsx | 33 + ui/goose2/src/features/home/ui/HomeScreen.tsx | 79 + .../settings/ui/AppearanceSettings.tsx | 140 + .../features/settings/ui/SettingsModal.tsx | 154 + ui/goose2/src/features/sidebar/ui/Sidebar.tsx | 90 + .../src/features/status/ui/StatusBar.tsx | 39 + ui/goose2/src/features/tabs/types.ts | 4 + ui/goose2/src/features/tabs/ui/TabBar.tsx | 91 + ui/goose2/src/main.tsx | 29 + ui/goose2/src/shared/api/.gitkeep | 0 ui/goose2/src/shared/constants/.gitkeep | 0 ui/goose2/src/shared/context/.gitkeep | 0 ui/goose2/src/shared/lib/cn.test.ts | 20 + ui/goose2/src/shared/lib/cn.ts | 6 + ui/goose2/src/shared/styles/globals.css | 199 + .../src/shared/theme/ThemeProvider.test.tsx | 78 + ui/goose2/src/shared/theme/ThemeProvider.tsx | 144 + ui/goose2/src/shared/ui/button.tsx | 56 + ui/goose2/src/test/setup.ts | 16 + ui/goose2/src/vite-env.d.ts | 1 + ui/goose2/tailwind.config.js | 93 + ui/goose2/tests/e2e/smoke.spec.ts | 35 + ui/goose2/tsconfig.json | 25 + ui/goose2/tsconfig.node.json | 10 + ui/goose2/vite.config.ts | 30 + ui/goose2/vitest.config.ts | 19 + 126 files changed, 15762 insertions(+) create mode 100644 ui/goose2/.github/workflows/ci.yml create mode 100644 ui/goose2/.gitignore create mode 100644 ui/goose2/AGENTS.md create mode 100644 ui/goose2/app-icon.png create mode 120000 ui/goose2/bin/.biome-2.4.9.pkg create mode 120000 ui/goose2/bin/.just-1.48.1.pkg create mode 120000 ui/goose2/bin/.lefthook-2.1.4.pkg create mode 120000 ui/goose2/bin/.node-24.14.1.pkg create mode 120000 ui/goose2/bin/.pnpm-10.33.0.pkg create mode 100644 ui/goose2/bin/README.hermit.md create mode 100755 ui/goose2/bin/activate-hermit create mode 100755 ui/goose2/bin/activate-hermit.fish create mode 120000 ui/goose2/bin/biome create mode 120000 ui/goose2/bin/corepack create mode 100755 ui/goose2/bin/hermit create mode 100644 ui/goose2/bin/hermit.hcl create mode 120000 ui/goose2/bin/just create mode 120000 ui/goose2/bin/lefthook create mode 120000 ui/goose2/bin/node create mode 120000 ui/goose2/bin/npm create mode 120000 ui/goose2/bin/npx create mode 120000 ui/goose2/bin/pnpm create mode 100644 ui/goose2/biome.json create mode 100644 ui/goose2/components.json create mode 100644 ui/goose2/index.html create mode 100644 ui/goose2/justfile create mode 100644 ui/goose2/lefthook.yml create mode 100644 ui/goose2/package.json create mode 100644 ui/goose2/playwright.config.ts create mode 100644 ui/goose2/pnpm-lock.yaml create mode 100644 ui/goose2/postcss.config.js create mode 100644 ui/goose2/scripts/check-file-sizes.mjs create mode 100644 ui/goose2/src-tauri/Cargo.lock create mode 100644 ui/goose2/src-tauri/Cargo.toml create mode 100644 ui/goose2/src-tauri/build.rs create mode 100644 ui/goose2/src-tauri/capabilities/default.json create mode 100644 ui/goose2/src-tauri/gen/schemas/acl-manifests.json create mode 100644 ui/goose2/src-tauri/gen/schemas/capabilities.json create mode 100644 ui/goose2/src-tauri/gen/schemas/desktop-schema.json create mode 100644 ui/goose2/src-tauri/gen/schemas/macOS-schema.json create mode 100644 ui/goose2/src-tauri/icons/128x128.png create mode 100644 ui/goose2/src-tauri/icons/128x128@2x.png create mode 100644 ui/goose2/src-tauri/icons/32x32.png create mode 100644 ui/goose2/src-tauri/icons/64x64.png create mode 100644 ui/goose2/src-tauri/icons/Square107x107Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square142x142Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square150x150Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square284x284Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square30x30Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square310x310Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square44x44Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square71x71Logo.png create mode 100644 ui/goose2/src-tauri/icons/Square89x89Logo.png create mode 100644 ui/goose2/src-tauri/icons/StoreLogo.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 ui/goose2/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 ui/goose2/src-tauri/icons/android/values/ic_launcher_background.xml create mode 100644 ui/goose2/src-tauri/icons/icon.icns create mode 100644 ui/goose2/src-tauri/icons/icon.ico create mode 100644 ui/goose2/src-tauri/icons/icon.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-20x20@1x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-20x20@2x-1.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-20x20@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-20x20@3x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-29x29@1x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-29x29@2x-1.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-29x29@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-29x29@3x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-40x40@1x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-40x40@2x-1.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-40x40@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-40x40@3x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-512@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-60x60@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-60x60@3x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-76x76@1x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-76x76@2x.png create mode 100644 ui/goose2/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png create mode 100644 ui/goose2/src-tauri/src/lib.rs create mode 100644 ui/goose2/src-tauri/src/main.rs create mode 100644 ui/goose2/src-tauri/tauri.conf.json create mode 100644 ui/goose2/src-tauri/tauri.dev.conf.json create mode 100644 ui/goose2/src/app/App.tsx create mode 100644 ui/goose2/src/app/AppShell.tsx create mode 100644 ui/goose2/src/env.d.ts create mode 100644 ui/goose2/src/features/chat/ui/ChatView.tsx create mode 100644 ui/goose2/src/features/home/ui/HomeScreen.test.tsx create mode 100644 ui/goose2/src/features/home/ui/HomeScreen.tsx create mode 100644 ui/goose2/src/features/settings/ui/AppearanceSettings.tsx create mode 100644 ui/goose2/src/features/settings/ui/SettingsModal.tsx create mode 100644 ui/goose2/src/features/sidebar/ui/Sidebar.tsx create mode 100644 ui/goose2/src/features/status/ui/StatusBar.tsx create mode 100644 ui/goose2/src/features/tabs/types.ts create mode 100644 ui/goose2/src/features/tabs/ui/TabBar.tsx create mode 100644 ui/goose2/src/main.tsx create mode 100644 ui/goose2/src/shared/api/.gitkeep create mode 100644 ui/goose2/src/shared/constants/.gitkeep create mode 100644 ui/goose2/src/shared/context/.gitkeep create mode 100644 ui/goose2/src/shared/lib/cn.test.ts create mode 100644 ui/goose2/src/shared/lib/cn.ts create mode 100644 ui/goose2/src/shared/styles/globals.css create mode 100644 ui/goose2/src/shared/theme/ThemeProvider.test.tsx create mode 100644 ui/goose2/src/shared/theme/ThemeProvider.tsx create mode 100644 ui/goose2/src/shared/ui/button.tsx create mode 100644 ui/goose2/src/test/setup.ts create mode 100644 ui/goose2/src/vite-env.d.ts create mode 100644 ui/goose2/tailwind.config.js create mode 100644 ui/goose2/tests/e2e/smoke.spec.ts create mode 100644 ui/goose2/tsconfig.json create mode 100644 ui/goose2/tsconfig.node.json create mode 100644 ui/goose2/vite.config.ts create mode 100644 ui/goose2/vitest.config.ts 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, + }, +});