- Add boocode-lift-analysis.md: comprehensive 30-repo lift matrix across 25 domains - Add openspec/ change docs: domain2-code-intelligence, domain3-multi-agent, impeccable-wave, streaming-codeblocks - Update .gitignore: .impeccable/, .omo/, bun.lock, DESIGN.md, PRODUCT.md - Update dependencies in package.json + pnpm-lock.yaml - Update .codesight/ analysis cache
11 KiB
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
@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
// 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:
- 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. - Pane close: Quick fade (100ms) — exit should be faster than enter per animation best practices.
- Pane reorder:
layoutprop on each pane wrapper. framer-motion handles the FLIP calculation automatically. - Tab switch: Content crossfade (150ms). Not a slide — tabs are content replacement, not navigation.
- Split pane: New pane emerges from the split point (scale + fade,
transform-originat the split edge).
Implementation
// Workspace.tsx — pane wrapper
<motion.div
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.1 } }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<PaneComponent />
</motion.div>
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.
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-animatedattribute. - 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
// 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:
- Delete chat — snapshot messages, restore on undo
- Archive project — toggle unarchive
- 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
// 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
// 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
<Routes>
<Route path="/" element={<Suspense fallback={<FullPageLoader />}><Home /></Suspense>} />
...
</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
// New reusable component
function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<div className="text-muted-foreground/40">{icon}</div>
<p className="text-sm font-medium text-foreground">{title}</p>
{description && (
<p className="text-xs text-muted-foreground text-center max-w-[240px]">{description}</p>
)}
{action && <Button variant="outline" size="sm" onClick={action.onClick}>{action.label}</Button>}
</div>
);
}
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
// No transform-origin set — rotation around center creates blur
<span className="absolute top-0 left-1/2 -translate-x-1/2 size-0.5 rounded-full bg-amber-500" />
Target
// Explicit transform-origin + motion-safe guard
<span className="absolute top-0 left-1/2 -translate-x-1/2 size-0.5 rounded-full bg-amber-500 motion-safe:animate-spin-slow motion-reduce:hidden" style={{ transformOrigin: 'center 6px' }} />
The size-0.5 dots orbit at 12px diameter. transform-origin: center 6px centers rotation at the orbit midpoint.