Files
boocode/openspec/changes/impeccable-wave/design.md
indifferentketchup 6fde7002aa docs: boocode-lift-analysis, openspec change docs, codesight cache, deps
- 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
2026-06-08 03:49:26 +00:00

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:

  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

// 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-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

// 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

// 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.