# Design Decisions — Impeccable Wave ## 1. Motion Token System ### Approach Define CSS custom properties for motion in `apps/web/src/styles/globals.css`, then reference from components. This is the standard pattern (CSS custom properties) that integrates natively with Tailwind v4's `@theme` directive. ### Token Schema ```css @theme { /* Durations */ --duration-instant: 0ms; --duration-fast: 100ms; --duration-normal: 150ms; --duration-slow: 200ms; --duration-deliberate: 300ms; /* Easing curves */ --ease-enter: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, button press */ --ease-move: cubic-bezier(0.25, 1, 0.5, 1); /* slides, drawers, panels */ --ease-hover: ease; /* hover states, color transitions */ --ease-emerge: cubic-bezier(0.165, 0.84, 0.44, 1); /* modals, popovers scaling in */ --ease-exit: cubic-bezier(0.4, 0, 0.2, 1); /* quick dismissals */ /* Reduced motion override */ --motion-reduce-transform: none; --motion-reduce-opacity: none; } ``` The `.dark` block or `@media (prefers-reduced-motion: reduce)` overrides `--motion-reduce-*` to `none` and sets duration overrides to `0ms`. ### Usage pattern ```tsx // Before className="transition-colors duration-150" // After className="transition-colors duration-[var(--duration-fast)] ease-[var(--ease-hover)]" ``` For framer-motion, reference same tokens via JS constants in `lib/motion.ts`. ## 2. Animation Library Choice: framer-motion ### Why framer-motion over alternatives | Feature | framer-motion | motion (standalone) | CSS-only | |---------|--------------|---------------------|----------| | Layout animations | `layout` prop | `layout` prop | ❌ Requires JS | | AnimatePresence | ✅ | ✅ | ❌ | | Spring physics | ✅ | ✅ | ❌ (CSS steps approximation) | | Gesture handling | `drag`, `whileHover`, `whileTap` | Same API | Partial | | Stagger children | `staggerChildren` variant | Same | ❌ | | Bundle size | ~30KB gzipped | ~10KB gzipped | 0KB | | React 18 compat | ✅ 12.x | ✅ 12.x | N/A | **Decision:** Use `framer-motion` (full-featured). The `motion` standalone package (same API, smaller bundle) is a future migration if bundle size becomes a concern. For this wave, framer-motion's broader ecosystem and established React 18 support are the safe choice. ### Bundle impact `framer-motion` v12 adds ~30KB gzipped. Route-level code splitting in Phase 5 will offset this by reducing the initial bundle by an estimated 100-200KB (the current bundle includes all 6 page components). Net bundle impact: **~70KB reduction** after code splitting. ## 3. Workspace Pane Animation Strategy ### Current architecture The workspace grid (`Workspace.tsx`) renders panes from `panes[]` array in a CSS grid. Pane open/close is a state mutation — panes appear/disappear instantly. Reorder is handled by drag (HTML5 native). ### Animated approach ``` ┌─────────────────────────────────┐ │ Workspace Grid │ │ ┌──────────┐ ┌──────────┐ │ │ │ Chat │ │ Terminal │ │ │ │ (enter: │ │ (enter: │ │ │ │ slideL) │ │ slideR) │ │ │ └──────────┘ └──────────┘ │ │ ┌──────────────────────────┐ │ │ │ Coder (enter: slideUp) │ │ │ └──────────────────────────┘ │ └─────────────────────────────────┘ ``` **Key decisions:** 1. **Pane open**: Slide in from the nearest edge (left-pane opens from left, right-pane from right, bottom-pane from bottom) with fade. 200ms `ease-out`. 2. **Pane close**: Quick fade (100ms) — exit should be faster than enter per animation best practices. 3. **Pane reorder**: `layout` prop on each pane wrapper. framer-motion handles the FLIP calculation automatically. 4. **Tab switch**: Content crossfade (150ms). Not a slide — tabs are content replacement, not navigation. 5. **Split pane**: New pane emerges from the split point (scale + fade, `transform-origin` at the split edge). ### Implementation ```tsx // Workspace.tsx — pane wrapper ``` `AnimatePresence` wraps the pane grid so exiting panes animate out before DOM removal. ## 4. Message Stagger Entrance ### Approach Use framer-motion's `staggerChildren` variant on the message list container, with individual item variants for the entrance. ```tsx const containerVariants = { initial: {}, animate: { transition: { staggerChildren: 0.03, // 30ms between messages }, }, }; const messageVariants = { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0, transition: { duration: 0.2, ease: 'easeOut' } }, }; ``` **Edge cases:** - **New messages during streaming**: Only animate the first appearance of a message. Once visible, subsequent content updates (streaming deltas) should not re-animate. Track via `data-animated` attribute. - **Scroll-anchored messages**: When the user is scrolled to bottom and a new message arrives, animate it in. When the user has scrolled up, suppress animation to avoid disorientation. - **Initial load**: All existing (already-loaded) messages appear without animation. Only NEW messages animate in. - **Reasoning block**: The collapsible reasoning block uses a spring-based height transition instead of the grid-rows CSS trick. ## 5. Keyboard Shortcuts Dialog ### Invocation `Cmd+/` or `?` in the workspace. A modal overlay triggered via a new keyboard handler in `Session.tsx`. ### Content Grouped into sections: ``` Navigation │ Cmd+` Terminal │ Cmd+T New Terminal │ Cmd+C New Chat │ Cmd+W Close Pane │ Tab / Shift+Tab │ Cycle panes Chat │ Enter Send │ Shift+Enter Newline │ Cmd+[ 1-9 ] │ Jump to tab │ @ │ Mention file │ / │ Slash commands Terminal │ Cmd+Shift+C │ Copy │ Cmd+F │ Search │ Esc │ Close search General │ Cmd+/ │ This panel │ ? │ This panel ``` ### Data source Derived from existing keyboard handlers in `Session.tsx` and component files. Centralized into a single `KEYBOARD_SHORTCUTS` constant in `lib/keyboard-shortcuts.ts`. ## 6. Undo Toast System ### Approach Extend the existing Sonner toast system. Destructive actions call `toast.success(message, { action: { label: 'Undo', onClick: rollback } })`. Each action needs a rollback function. ### Implementation pattern ```typescript // In component with destructive action async function deleteChat(chatId: string) { const previousMessages = await api.chats.messages.list(chatId); // snapshot await api.chats.remove(chatId); // optimistic delete toast.success('Chat deleted', { action: { label: 'Undo', onClick: async () => { await api.chats.restore(chatId, previousMessages); // rollback }, }, duration: 5000, // 5s to undo }); } ``` ### Scope Start with 3 high-impact undo actions: 1. **Delete chat** — snapshot messages, restore on undo 2. **Archive project** — toggle unarchive 3. **Close pane** — reopen via `closedPaneStack` (already exists) ## 7. Font Pairing Strategy ### Goals - Distinctive but still technical/developer-appropriate - Must pair well with JetBrains Mono (code font stays) - Must support variable weights for the Tailwind v4 font-weight system - Must be available on Google Fonts (self-hosted via `@fontsource`) ### Candidate pairs | Pair | Vibe | Google Fonts | Variable | |------|------|-------------|----------| | **Instrument Sans** + JetBrains Mono | Modern, geometric, technical | ✅ | ✅ | | **Public Sans** + JetBrains Mono | Clean, neutral, authoritative | ✅ | ✅ | | **Satoshi** + JetBrains Mono | Bold, editorial, confident | ❌ (paid) | ✅ | | **Onest** + JetBrains Mono | Rounded, approachable but tech | ✅ | ✅ | | **Plus Jakarta Sans** + JetBrains Mono | Refined, slightly warmer | ✅ | ✅ | **Decision:** Defer final choice to implementation. Replace Inter with chosen font via `@fontsource` import in `main.tsx` + update the `--font-sans` token in `globals.css`. ## 8. Code Splitting Architecture ### Current ```tsx // App.tsx — static imports, all loaded upfront import Home from '@/pages/Home' import Project from '@/pages/Project' import Session from '@/pages/Session' import Settings from '@/pages/Settings' import Analytics from '@/pages/Analytics' import Results from '@/pages/Results' ``` ### Target ```tsx // App.tsx — dynamic imports with named exports const Home = lazy(() => import('@/pages/Home')) const Project = lazy(() => import('@/pages/Project')) const Session = lazy(() => import('@/pages/Session')) const Settings = lazy(() => import('@/pages/Settings')) const Analytics = lazy(() => import('@/pages/Analytics')) const Results = lazy(() => import('@/pages/Results')) // Single Suspense boundary wrapping all routes }>} /> ... ``` ### Chunk strategy - **Vendor chunk**: `react`, `react-dom`, `react-router-dom` → stable, infrequently changed - **UI chunk**: `framer-motion`, `lucide-react`, `react-markdown`, `shiki` → large UI libs - **Per-page chunks**: Each page component loads on demand Configured via `vite.config.ts` `build.rollupOptions.output.manualChunks`. ## 9. Empty State Pattern ### Standard template ```tsx // New reusable component function EmptyState({ icon, title, description, action }: EmptyStateProps) { return (
{icon}

{title}

{description && (

{description}

)} {action && }
); } ``` Replace current text-only empty states in `Home.tsx` (no projects), `Project.tsx` (no sessions), and `SessionLandingPage.tsx` (no chats). ## 10. StatusDot Animation Fix ### Current ```tsx // No transform-origin set — rotation around center creates blur ``` ### Target ```tsx // Explicit transform-origin + motion-safe guard ``` The `size-0.5` dots orbit at 12px diameter. `transform-origin: center 6px` centers rotation at the orbit midpoint.