# Motion, Dark Mode & Custom Titlebar Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add playful bouncy Framer Motion animations to every interaction, lighten dark mode for HDR monitors, and implement a custom window titlebar merged into the TopBar. **Architecture:** Centralized motion config (`src/lib/motion.ts`) defines shared spring presets and reusable variants. All components import from this single source. Dark mode is pure CSS variable tuning in `index.css`. Custom titlebar disables native decorations via Tauri config and embeds window controls into the existing TopBar component. **Tech Stack:** Framer Motion 12, React 19, TypeScript, Tauri v2 window API, OKLCH color space, dnd-kit, Tailwind CSS 4 --- ### Task 1: Create shared motion config **Files:** - Create: `src/lib/motion.ts` **Step 1: Create the motion config file** Create `src/lib/motion.ts` with spring presets, reusable animation variants, and stagger helper: ```typescript import type { Transition, Variants } from "framer-motion"; // --- Spring presets --- export const springs = { bouncy: { type: "spring", stiffness: 400, damping: 15, mass: 0.8 } as Transition, snappy: { type: "spring", stiffness: 500, damping: 20 } as Transition, gentle: { type: "spring", stiffness: 200, damping: 20 } as Transition, wobbly: { type: "spring", stiffness: 300, damping: 10 } as Transition, }; // --- Reusable variants --- export const fadeSlideUp: Variants = { hidden: { opacity: 0, y: 12 }, visible: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 }, }; export const fadeSlideDown: Variants = { hidden: { opacity: 0, y: -12 }, visible: { opacity: 1, y: 0 }, exit: { opacity: 0, y: 8 }, }; export const fadeSlideLeft: Variants = { hidden: { opacity: 0, x: 40 }, visible: { opacity: 1, x: 0 }, exit: { opacity: 0, x: -40 }, }; export const fadeSlideRight: Variants = { hidden: { opacity: 0, x: -40 }, visible: { opacity: 1, x: 0 }, exit: { opacity: 0, x: 40 }, }; export const scaleIn: Variants = { hidden: { opacity: 0, scale: 0.9 }, visible: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: 0.95 }, }; // --- Stagger container --- export function staggerContainer(staggerDelay = 0.04): Variants { return { hidden: {}, visible: { transition: { staggerChildren: staggerDelay, }, }, }; } // --- Micro-interaction presets --- export const microInteraction = { hover: { scale: 1.05 }, tap: { scale: 0.95 }, }; export const subtleHover = { hover: { scale: 1.02 }, tap: { scale: 0.98 }, }; ``` **Step 2: Verify TypeScript compiles** Run: `npx tsc --noEmit` Expected: No errors from `src/lib/motion.ts` **Step 3: Commit** ```bash git add src/lib/motion.ts git commit -m "feat: add shared motion config with spring presets and variants" ``` --- ### Task 2: Update dark mode CSS variables **Files:** - Modify: `src/index.css:101-140` (the `.dark` block) **Step 1: Update the `.dark` block in `src/index.css`** Replace the entire `.dark { ... }` block (lines 101-140) with these lightened values: ```css .dark { --background: oklch(0.22 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.27 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.27 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.32 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.32 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.32 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 12%); --input: oklch(1 0 0 / 18%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.27 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.32 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 12%); --sidebar-ring: oklch(0.556 0 0); --pylon-bg: oklch(25% 0.012 50); --pylon-surface: oklch(29% 0.012 50); --pylon-column: oklch(27% 0.014 50); --pylon-accent: oklch(62% 0.13 160); --pylon-text: oklch(92% 0.01 50); --pylon-text-secondary: oklch(58% 0.01 50); --pylon-danger: oklch(62% 0.18 25); } ``` **Step 2: Visual test** Run: `npm run tauri dev` Toggle dark mode in Settings. Verify backgrounds are noticeably lighter (charcoal, not cave-black). Colors should still feel warm. **Step 3: Commit** ```bash git add src/index.css git commit -m "feat: lighten dark mode for HDR monitors — bump pylon + shadcn values" ``` --- ### Task 3: Custom window titlebar — Tauri config + WindowControls component **Files:** - Modify: `src-tauri/tauri.conf.json:13-19` (windows config) - Create: `src/components/layout/WindowControls.tsx` - Modify: `src/components/layout/TopBar.tsx` **Step 1: Disable native decorations in tauri.conf.json** In `src-tauri/tauri.conf.json`, add `"decorations": false` to the window config object: ```json "windows": [ { "title": "OpenPylon", "width": 1200, "height": 800, "minWidth": 800, "minHeight": 600, "decorations": false } ] ``` **Step 2: Create WindowControls component** Create `src/components/layout/WindowControls.tsx`: ```tsx import { useState, useEffect } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { motion } from "framer-motion"; import { Minus, Square, Copy, X } from "lucide-react"; import { springs } from "@/lib/motion"; export function WindowControls() { const [isMaximized, setIsMaximized] = useState(false); useEffect(() => { const appWindow = getCurrentWindow(); // Check initial state appWindow.isMaximized().then(setIsMaximized); // Listen for resize events to track maximize state const unlisten = appWindow.onResized(async () => { const maximized = await appWindow.isMaximized(); setIsMaximized(maximized); }); return () => { unlisten.then((fn) => fn()); }; }, []); const appWindow = getCurrentWindow(); return (
{/* Separator */}
{/* Minimize */} appWindow.minimize()} className="flex size-8 items-center justify-center rounded-md text-pylon-text-secondary transition-colors hover:bg-pylon-accent/10 hover:text-pylon-text" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} transition={springs.snappy} aria-label="Minimize" > {/* Maximize / Restore */} appWindow.toggleMaximize()} className="flex size-8 items-center justify-center rounded-md text-pylon-text-secondary transition-colors hover:bg-pylon-accent/10 hover:text-pylon-text" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} transition={springs.snappy} aria-label={isMaximized ? "Restore" : "Maximize"} > {isMaximized ? ( ) : ( )} {/* Close */} appWindow.close()} className="flex size-8 items-center justify-center rounded-md text-pylon-text-secondary transition-colors hover:bg-pylon-danger/15 hover:text-pylon-danger" whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} transition={springs.snappy} aria-label="Close" >
); } ``` **Step 3: Integrate WindowControls into TopBar** In `src/components/layout/TopBar.tsx`, add this import at the top: ```typescript import { WindowControls } from "@/components/layout/WindowControls"; ``` Then, at the very end of the right section `
` (after the Settings tooltip, before the closing `
` on line 241), add: ```tsx ``` **Step 4: Test** Run: `npm run tauri dev` Verify: Native titlebar is gone. Custom minimize/maximize/close buttons appear in TopBar right side. Window dragging still works via the header. All three buttons function correctly. **Step 5: Commit** ```bash git add src-tauri/tauri.conf.json src/components/layout/WindowControls.tsx src/components/layout/TopBar.tsx git commit -m "feat: custom window titlebar — remove native decorations, add WindowControls to TopBar" ``` --- ### Task 4: Page transitions in App.tsx **Files:** - Modify: `src/App.tsx` **Step 1: Add AnimatePresence page transitions** Replace the imports and the view rendering section in `src/App.tsx`. Add to imports: ```typescript import { AnimatePresence, motion } from "framer-motion"; import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion"; ``` Replace line 76 (the view conditional): ```tsx {view.type === "board-list" ? : } ``` With: ```tsx {view.type === "board-list" ? ( ) : ( )} ``` **Step 2: Test** Run: `npm run tauri dev` Navigate between board list and board view. Verify smooth slide transitions with gentle spring. **Step 3: Commit** ```bash git add src/App.tsx git commit -m "feat: add AnimatePresence page transitions between views" ``` --- ### Task 5: Board list stagger animations **Files:** - Modify: `src/components/boards/BoardList.tsx` - Modify: `src/components/boards/BoardCard.tsx` **Step 1: Add stagger container to BoardList** In `src/components/boards/BoardList.tsx`, add imports: ```typescript import { motion } from "framer-motion"; import { staggerContainer, fadeSlideUp, springs, scaleIn } from "@/lib/motion"; ``` Replace the empty state content (lines 27-44, the `
` block) — wrap the inner content with motion for fade-in: ```tsx

Welcome to OpenPylon

A local-first Kanban board that keeps your data on your machine. Create your first board to get started.

``` Replace the board grid `
` (line 68): ```tsx
``` With a `motion.div` stagger container: ```tsx ``` **Step 2: Update BoardCard to use shared variants** In `src/components/boards/BoardCard.tsx`, add imports: ```typescript import { fadeSlideUp, springs, subtleHover } from "@/lib/motion"; ``` Replace the existing `` wrapper (lines 76-80): ```tsx ``` With: ```tsx ``` Remove the `index` prop from the `BoardCardProps` interface and function signature — stagger is now handled by the parent container. Also remove the `index = 0` default parameter. **Step 3: Commit** ```bash git add src/components/boards/BoardList.tsx src/components/boards/BoardCard.tsx git commit -m "feat: add stagger animations to board list and board cards" ``` --- ### Task 6: Board view — column stagger + card stagger **Files:** - Modify: `src/components/board/BoardView.tsx` - Modify: `src/components/board/KanbanColumn.tsx` - Modify: `src/components/board/CardThumbnail.tsx` **Step 1: Add column stagger to BoardView** In `src/components/board/BoardView.tsx`, add imports: ```typescript import { motion } from "framer-motion"; import { staggerContainer, springs } from "@/lib/motion"; ``` Wrap the column list container (line 323, the `
`) — replace the `
` with ``: ```tsx ``` Also change the closing `
` (line 378) to ``. **Step 2: Update KanbanColumn to use shared config** In `src/components/board/KanbanColumn.tsx`, update the import: ```typescript import { motion, useReducedMotion } from "framer-motion"; ``` Add: ```typescript import { fadeSlideUp, springs, staggerContainer } from "@/lib/motion"; ``` Replace the `` props (lines 66-74): ```tsx ``` With: ```tsx ``` Wrap the `
    ` card list (line 87) with stagger. Replace the `
      `: ```tsx
        ``` With a `motion.ul`: ```tsx ``` Change the closing `
      ` to ``. **Step 3: Update CardThumbnail to use shared config** In `src/components/board/CardThumbnail.tsx`, add imports: ```typescript import { fadeSlideUp, springs, subtleHover } from "@/lib/motion"; ``` Replace the `` props (lines 49-56): ```tsx `: After the `layout` prop, add: ```tsx layoutId={`card-${card.id}`} ``` **Step 2: Replace Radix Dialog with custom Framer Motion modal in CardDetailModal** This is the most complex change. We replace the Radix `` with a custom modal using Framer Motion's `AnimatePresence` and `motion.div` with a matching `layoutId`. Rewrite `src/components/card-detail/CardDetailModal.tsx`. Replace the entire Dialog-based return block (lines 31-93) with: ```tsx import { AnimatePresence, motion } from "framer-motion"; import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion"; ``` Add these imports at the top of the file (alongside existing imports). Then replace the `return (...)` block: ```tsx return ( {open && card && cardId && ( <> {/* Backdrop */} {/* Modal */}
      e.stopPropagation()} > {/* Close on Escape */} {/* Hidden accessible description */} Card detail editor {/* Left panel: Title + Markdown (60%) */}
      {/* Vertical separator */} {/* Right sidebar (40%) */}
      )}
      ); ``` Add a small `EscapeHandler` component at the bottom of the file: ```tsx function EscapeHandler({ onClose }: { onClose: () => void }) { useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [onClose]); return null; } ``` Remove the Radix Dialog imports that are no longer needed: ```typescript // REMOVE these imports: import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; ``` Keep `DialogTitle` if used by `InlineTitle` — actually `InlineTitle` uses `DialogTitle` on line 155. Replace that with a plain `

      `: In the `InlineTitle` component, change: ```tsx setEditing(true)} className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent" > {title} ``` To: ```tsx

      setEditing(true)} className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent" > {title}

      ``` **Step 3: Wrap the CardDetailModal in the parent with proper context** In `src/components/board/BoardView.tsx`, the `` is already rendered outside the ``. No changes needed — `AnimatePresence` is now inside `CardDetailModal` itself. **Step 4: Test** Run: `npm run tauri dev` Click a card. It should morph/expand from its position into the modal. The backdrop should blur in. Close should reverse the animation. Content sections should stagger in. **Step 5: Commit** ```bash git add src/components/board/CardThumbnail.tsx src/components/card-detail/CardDetailModal.tsx git commit -m "feat: shared layout animation — card expands into detail modal" ``` --- ### Task 8: Gesture-reactive drag overlay **Files:** - Modify: `src/components/board/DragOverlayContent.tsx` - Modify: `src/components/board/BoardView.tsx` **Step 1: Create motion-powered drag overlay** Rewrite `src/components/board/DragOverlayContent.tsx` to use Framer Motion with gesture-reactive tilt: ```tsx import { useRef } from "react"; import { motion, useMotionValue, useTransform, animate } from "framer-motion"; import type { Card, Column, Label } from "@/types/board"; import { LabelDots } from "@/components/board/LabelDots"; import { ChecklistBar } from "@/components/board/ChecklistBar"; import { format, isPast, isToday } from "date-fns"; import { springs } from "@/lib/motion"; interface CardOverlayProps { card: Card; boardLabels: Label[]; } export function CardOverlay({ card, boardLabels }: CardOverlayProps) { const hasDueDate = card.dueDate != null; const dueDate = hasDueDate ? new Date(card.dueDate!) : null; const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate); const rotate = useMotionValue(0); const scale = useMotionValue(1.05); // Track pointer movement for tilt const lastX = useRef(0); return ( { const deltaX = e.clientX - lastX.current; lastX.current = e.clientX; // Tilt based on horizontal velocity, clamped to ±5 degrees const tilt = Math.max(-5, Math.min(5, deltaX * 0.3)); animate(rotate, tilt, { type: "spring", stiffness: 300, damping: 20 }); }} > {/* Cover color bar */} {card.coverColor && (
      )} {/* Label dots */} {card.labels.length > 0 && (
      )} {/* Card title */}

      {card.title}

      {/* Footer row */} {(hasDueDate || card.checklist.length > 0) && (
      {dueDate && ( {format(dueDate, "MMM d")} )} {card.checklist.length > 0 && ( )}
      )} ); } interface ColumnOverlayProps { column: Column; } export function ColumnOverlay({ column }: ColumnOverlayProps) { return (
      {column.title} {column.cardIds.length}
      {column.cardIds.slice(0, 3).map((_, i) => (
      ))} {column.cardIds.length > 3 && (

      +{column.cardIds.length - 3} more

      )}
      ); } ``` **Step 2: Wrap DragOverlay in AnimatePresence** In `src/components/board/BoardView.tsx`, add `AnimatePresence` import: ```typescript import { AnimatePresence } from "framer-motion"; ``` Wrap the `` contents with AnimatePresence. Replace lines 382-388: ```tsx {activeCard ? ( ) : activeColumn ? ( ) : null} ``` With: ```tsx {activeCard ? ( ) : activeColumn ? ( ) : null} ``` Note: `dropAnimation={null}` disables dnd-kit's built-in drop animation since we handle it with Framer Motion. **Step 3: Commit** ```bash git add src/components/board/DragOverlayContent.tsx src/components/board/BoardView.tsx git commit -m "feat: gesture-reactive drag overlay with tilt based on pointer velocity" ``` --- ### Task 9: TopBar micro-interactions **Files:** - Modify: `src/components/layout/TopBar.tsx` **Step 1: Add motion to TopBar buttons and saving status** In `src/components/layout/TopBar.tsx`, add imports: ```typescript import { motion, AnimatePresence } from "framer-motion"; import { springs } from "@/lib/motion"; ``` Wrap each icon `