Files
openpylon/docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md
Your Name 3703857ccf docs: add motion, dark mode & titlebar implementation plan
13-task plan covering shared motion config, dark mode CSS tuning,
custom window titlebar, page transitions, stagger animations,
shared layout card-to-modal animation, gesture-reactive drag,
and micro-interactions across all components.
2026-02-15 20:55:31 +02:00

1274 lines
36 KiB
Markdown

# 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 (
<div className="flex items-center">
{/* Separator */}
<div className="mx-2 h-4 w-px bg-pylon-text-secondary/20" />
{/* Minimize */}
<motion.button
onClick={() => 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"
>
<Minus className="size-4" />
</motion.button>
{/* Maximize / Restore */}
<motion.button
onClick={() => 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 ? (
<Copy className="size-3.5" />
) : (
<Square className="size-3.5" />
)}
</motion.button>
{/* Close */}
<motion.button
onClick={() => 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"
>
<X className="size-4" />
</motion.button>
</div>
);
}
```
**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 `<div>` (after the Settings tooltip, before the closing `</div>` on line 241), add:
```tsx
<WindowControls />
```
**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" ? <BoardList /> : <BoardView />}
```
With:
```tsx
<AnimatePresence mode="wait">
{view.type === "board-list" ? (
<motion.div
key="board-list"
className="h-full"
variants={fadeSlideRight}
initial="hidden"
animate="visible"
exit="exit"
transition={springs.gentle}
>
<BoardList />
</motion.div>
) : (
<motion.div
key={`board-${view.boardId}`}
className="h-full"
variants={fadeSlideLeft}
initial="hidden"
animate="visible"
exit="exit"
transition={springs.gentle}
>
<BoardView />
</motion.div>
)}
</AnimatePresence>
```
**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 `<div className="flex h-full flex-col items-center justify-center gap-6">` block) — wrap the inner content with motion for fade-in:
```tsx
<motion.div
className="flex h-full flex-col items-center justify-center gap-6"
variants={scaleIn}
initial="hidden"
animate="visible"
transition={springs.gentle}
>
<div className="text-center">
<h2 className="font-heading text-2xl text-pylon-text">
Welcome to OpenPylon
</h2>
<p className="mt-2 max-w-sm text-sm text-pylon-text-secondary">
A local-first Kanban board that keeps your data on your machine.
Create your first board to get started.
</p>
</div>
<div className="flex items-center gap-3">
<Button size="lg" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Create Board
</Button>
<ImportExportButtons />
</div>
</motion.div>
```
Replace the board grid `<div>` (line 68):
```tsx
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
```
With a `motion.div` stagger container:
```tsx
<motion.div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
>
```
**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 `<motion.div>` wrapper (lines 76-80):
```tsx
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25, delay: index * 0.05 }}
>
```
With:
```tsx
<motion.div
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
transition={springs.bouncy}
whileHover={subtleHover.hover}
whileTap={subtleHover.tap}
>
```
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 `<div className="flex h-full overflow-x-auto" ...>`) — replace the `<div>` with `<motion.div>`:
```tsx
<motion.div
className="flex h-full overflow-x-auto"
style={{ gap: `calc(1.5rem * var(--density-factor))`, padding: `calc(1.5rem * var(--density-factor))`, ...getBoardBackground(board) }}
variants={staggerContainer(0.06)}
initial="hidden"
animate="visible"
>
```
Also change the closing `</div>` (line 378) to `</motion.div>`.
**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 `<motion.section>` props (lines 66-74):
```tsx
<motion.section
ref={setSortableNodeRef}
style={style}
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
initial={prefersReducedMotion ? false : { opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
{...attributes}
>
```
With:
```tsx
<motion.section
ref={setSortableNodeRef}
style={style}
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
transition={springs.bouncy}
layout
{...attributes}
>
```
Wrap the `<ul>` card list (line 87) with stagger. Replace the `<ul>`:
```tsx
<ul ref={setDroppableNodeRef} className="flex min-h-[40px] list-none flex-col" style={{ gap: `calc(0.5rem * var(--density-factor))`, padding: `calc(0.5rem * var(--density-factor))` }}>
```
With a `motion.ul`:
```tsx
<motion.ul
ref={setDroppableNodeRef}
className="flex min-h-[40px] list-none flex-col"
style={{ gap: `calc(0.5rem * var(--density-factor))`, padding: `calc(0.5rem * var(--density-factor))` }}
variants={staggerContainer(0.03)}
initial="hidden"
animate="visible"
>
```
Change the closing `</ul>` to `</motion.ul>`.
**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 `<motion.button>` props (lines 49-56):
```tsx
<motion.button
ref={setNodeRef}
style={style}
onClick={handleClick}
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left transition-all duration-200 hover:-translate-y-px hover:shadow-md"
initial={prefersReducedMotion ? false : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
```
With:
```tsx
<motion.button
ref={setNodeRef}
style={style}
onClick={handleClick}
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left"
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
whileHover={{ scale: 1.02, y: -2, boxShadow: "0 4px 12px oklch(0% 0 0 / 10%)" }}
whileTap={{ scale: 0.98 }}
transition={springs.bouncy}
layout
```
Remove the Tailwind hover classes `hover:-translate-y-px hover:shadow-md` since Framer Motion handles hover now. Also remove `transition-all duration-200`.
**Step 4: Commit**
```bash
git add src/components/board/BoardView.tsx src/components/board/KanbanColumn.tsx src/components/board/CardThumbnail.tsx
git commit -m "feat: add column stagger + card stagger + card hover/tap animations"
```
---
### Task 7: Card detail modal — shared layout animation
**Files:**
- Modify: `src/components/board/CardThumbnail.tsx`
- Modify: `src/components/card-detail/CardDetailModal.tsx`
- Modify: `src/components/board/BoardView.tsx`
**Step 1: Add layoutId to CardThumbnail**
In `src/components/board/CardThumbnail.tsx`, add `layoutId` to the `<motion.button>`:
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 `<Dialog>` 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 (
<AnimatePresence>
{open && card && cardId && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<motion.div
layoutId={`card-${cardId}`}
className="relative w-full max-w-3xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
transition={springs.gentle}
onClick={(e) => e.stopPropagation()}
>
{/* Close on Escape */}
<EscapeHandler onClose={onClose} />
{/* Hidden accessible description */}
<span className="sr-only">Card detail editor</span>
<motion.div
className="flex max-h-[80vh] flex-col sm:flex-row"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
>
{/* Left panel: Title + Markdown (60%) */}
<motion.div
className="flex flex-1 flex-col overflow-y-auto p-6 sm:w-[60%]"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<div className="mb-4">
<InlineTitle
cardId={cardId}
title={card.title}
updateCard={updateCard}
/>
</div>
<MarkdownEditor cardId={cardId} value={card.description} />
</motion.div>
{/* Vertical separator */}
<Separator orientation="vertical" className="hidden sm:block" />
{/* Right sidebar (40%) */}
<motion.div
className="flex flex-col gap-5 overflow-y-auto border-t border-border p-5 sm:w-[40%] sm:border-t-0"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CoverColorPicker cardId={cardId} coverColor={card.coverColor} />
<Separator />
<LabelPicker
cardId={cardId}
cardLabelIds={card.labels}
boardLabels={boardLabels}
/>
<Separator />
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
<Separator />
<ChecklistSection
cardId={cardId}
checklist={card.checklist}
/>
<Separator />
<AttachmentSection
cardId={cardId}
attachments={card.attachments}
/>
</motion.div>
</motion.div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
```
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 `<h2>`:
In the `InlineTitle` component, change:
```tsx
<DialogTitle
onClick={() => setEditing(true)}
className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent"
>
{title}
</DialogTitle>
```
To:
```tsx
<h2
onClick={() => setEditing(true)}
className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent"
>
{title}
</h2>
```
**Step 3: Wrap the CardDetailModal in the parent with proper context**
In `src/components/board/BoardView.tsx`, the `<CardDetailModal>` is already rendered outside the `<DndContext>`. 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 (
<motion.div
className="w-[260px] cursor-grabbing rounded-lg bg-pylon-surface p-3 shadow-xl"
style={{ rotate, scale }}
initial={{ scale: 1, rotate: 0 }}
animate={{ scale: 1.05, boxShadow: "0 20px 40px oklch(0% 0 0 / 20%)" }}
exit={{ scale: 1, rotate: 0 }}
transition={springs.bouncy}
onPointerMove={(e) => {
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 && (
<div
className="mb-2 -mx-3 -mt-3 h-1 rounded-t-lg"
style={{ backgroundColor: `oklch(55% 0.12 ${card.coverColor})` }}
/>
)}
{/* Label dots */}
{card.labels.length > 0 && (
<div className="mb-2">
<LabelDots labelIds={card.labels} boardLabels={boardLabels} />
</div>
)}
{/* Card title */}
<p className="text-sm font-medium text-pylon-text">{card.title}</p>
{/* Footer row */}
{(hasDueDate || card.checklist.length > 0) && (
<div className="mt-2 flex items-center gap-3">
{dueDate && (
<span
className={`font-mono text-xs ${
overdue
? "rounded bg-pylon-danger/10 px-1 py-0.5 text-pylon-danger"
: "text-pylon-text-secondary"
}`}
>
{format(dueDate, "MMM d")}
</span>
)}
{card.checklist.length > 0 && (
<ChecklistBar checklist={card.checklist} />
)}
</div>
)}
</motion.div>
);
}
interface ColumnOverlayProps {
column: Column;
}
export function ColumnOverlay({ column }: ColumnOverlayProps) {
return (
<motion.div
className="w-[280px] cursor-grabbing rounded-lg bg-pylon-column p-3 shadow-xl"
initial={{ scale: 1, rotate: 0 }}
animate={{ scale: 1.03, rotate: 1, boxShadow: "0 20px 40px oklch(0% 0 0 / 20%)" }}
exit={{ scale: 1, rotate: 0 }}
transition={springs.bouncy}
>
<div className="flex items-center gap-2 border-b border-border pb-2">
<span className="truncate font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
{column.title}
</span>
<span className="shrink-0 font-mono text-xs text-pylon-text-secondary">
{column.cardIds.length}
</span>
</div>
<div className="mt-2 space-y-1">
{column.cardIds.slice(0, 3).map((_, i) => (
<div key={i} className="h-6 rounded bg-pylon-surface/50" />
))}
{column.cardIds.length > 3 && (
<p className="text-center font-mono text-xs text-pylon-text-secondary">
+{column.cardIds.length - 3} more
</p>
)}
</div>
</motion.div>
);
}
```
**Step 2: Wrap DragOverlay in AnimatePresence**
In `src/components/board/BoardView.tsx`, add `AnimatePresence` import:
```typescript
import { AnimatePresence } from "framer-motion";
```
Wrap the `<DragOverlay>` contents with AnimatePresence. Replace lines 382-388:
```tsx
<DragOverlay>
{activeCard ? (
<CardOverlay card={activeCard} boardLabels={board.labels} />
) : activeColumn ? (
<ColumnOverlay column={activeColumn} />
) : null}
</DragOverlay>
```
With:
```tsx
<DragOverlay dropAnimation={null}>
<AnimatePresence>
{activeCard ? (
<CardOverlay key="card-overlay" card={activeCard} boardLabels={board.labels} />
) : activeColumn ? (
<ColumnOverlay key="column-overlay" column={activeColumn} />
) : null}
</AnimatePresence>
</DragOverlay>
```
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 `<Button>` in the right section with `motion` micro-interactions. Since the `<Button>` component uses `asChild` pattern with Radix Tooltip, we wrap the `<Button>` itself. The simplest approach: convert each bare `<Button>` to a `motion.div` wrapper with `whileHover` and `whileTap`.
Replace the saving status span (lines 201-204):
```tsx
{savingStatus && (
<span className="mr-2 font-mono text-xs text-pylon-text-secondary">
{savingStatus}
</span>
)}
```
With AnimatePresence for fade in/out:
```tsx
<AnimatePresence mode="wait">
{savingStatus && (
<motion.span
key={savingStatus}
className="mr-2 font-mono text-xs text-pylon-text-secondary"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={springs.snappy}
>
{savingStatus}
</motion.span>
)}
</AnimatePresence>
```
**Step 2: Commit**
```bash
git add src/components/layout/TopBar.tsx
git commit -m "feat: add micro-animations to TopBar saving status"
```
---
### Task 10: Toast animations — wobbly spring
**Files:**
- Modify: `src/components/toast/ToastContainer.tsx`
**Step 1: Update toast spring to wobbly preset**
In `src/components/toast/ToastContainer.tsx`, add import:
```typescript
import { springs } from "@/lib/motion";
```
Replace the `<motion.div>` transition (line 22):
```tsx
transition={{ type: "spring", stiffness: 400, damping: 25 }}
```
With:
```tsx
transition={springs.wobbly}
```
Also update the exit to slide down:
```tsx
exit={{ opacity: 0, y: 20, scale: 0.9 }}
```
**Step 2: Commit**
```bash
git add src/components/toast/ToastContainer.tsx
git commit -m "feat: use wobbly spring for toast notifications"
```
---
### Task 11: Settings dialog — tab crossfade + swatch hover
**Files:**
- Modify: `src/components/settings/SettingsDialog.tsx`
**Step 1: Add AnimatePresence for tab content crossfade**
In `src/components/settings/SettingsDialog.tsx`, add imports:
```typescript
import { motion, AnimatePresence } from "framer-motion";
import { springs, scaleIn, microInteraction } from "@/lib/motion";
```
Wrap the tab content section (line 123, `<div className="flex flex-col gap-5 pt-1">`) with AnimatePresence:
```tsx
<AnimatePresence mode="wait">
<motion.div
key={tab}
className="flex flex-col gap-5 pt-1"
variants={scaleIn}
initial="hidden"
animate="visible"
exit="exit"
transition={springs.snappy}
>
{/* ... existing tab content unchanged ... */}
</motion.div>
</AnimatePresence>
```
For the accent color swatch buttons (line 196-210), add `motion.button` with hover animation. Replace each `<button>` in the ACCENT_PRESETS map:
```tsx
<motion.button
key={hue}
type="button"
onClick={() => setAccentColor(hue)}
className="size-7 rounded-full"
style={{
backgroundColor: bg,
outline: settings.accentColor === hue ? "2px solid currentColor" : "none",
outlineOffset: "2px",
}}
whileHover={microInteraction.hover}
whileTap={microInteraction.tap}
transition={springs.snappy}
aria-label={label}
title={label}
/>
```
Remove the `transition-transform hover:scale-110` Tailwind classes since Framer Motion handles it.
**Step 2: Commit**
```bash
git add src/components/settings/SettingsDialog.tsx
git commit -m "feat: add tab crossfade and swatch hover animations to settings"
```
---
### Task 12: Shortcut help modal entrance animation
**Files:**
- Modify: `src/components/shortcuts/ShortcutHelpModal.tsx`
**Step 1: Add entrance animation**
In `src/components/shortcuts/ShortcutHelpModal.tsx`, add imports:
```typescript
import { motion, AnimatePresence } from "framer-motion";
import { springs, scaleIn, staggerContainer, fadeSlideUp } from "@/lib/motion";
```
The current implementation uses Radix Dialog which handles its own open/close. We can add motion to the content inside. Replace the `<div className="flex flex-col gap-4">` (line 45) with a motion stagger container:
```tsx
<motion.div
className="flex flex-col gap-4"
variants={staggerContainer(0.06)}
initial="hidden"
animate="visible"
>
{SHORTCUT_GROUPS.map((group) => (
<motion.div key={group.category} variants={fadeSlideUp} transition={springs.bouncy}>
<h4 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
{group.category}
</h4>
<div className="flex flex-col gap-1">
{group.shortcuts.map(({ key, description }) => (
<div key={key} className="flex items-center justify-between py-1">
<span className="text-sm text-pylon-text">{description}</span>
<kbd className="rounded bg-pylon-column px-2 py-0.5 font-mono text-xs text-pylon-text-secondary">
{key}
</kbd>
</div>
))}
</div>
</motion.div>
))}
</motion.div>
```
**Step 2: Commit**
```bash
git add src/components/shortcuts/ShortcutHelpModal.tsx
git commit -m "feat: add stagger entrance animation to shortcut help modal"
```
---
### Task 13: Polish pass — verify reduced-motion + TypeScript check
**Files:**
- Modify: `src/lib/motion.ts` (if needed)
- Verify all files compile
**Step 1: Run TypeScript check**
Run: `npx tsc --noEmit`
Expected: No errors. Fix any type issues that arise.
**Step 2: Test reduced-motion**
In the browser dev tools, run: `document.documentElement.style.setProperty('prefers-reduced-motion', 'reduce')` or use the browser's rendering tools to emulate reduced-motion.
Verify: All Framer Motion animations respect `useReducedMotion()` where it's already used (KanbanColumn, CardThumbnail, BoardCard). The CSS `prefers-reduced-motion` media query in `index.css` already handles CSS transitions.
**Step 3: Visual test all animations**
Run: `npm run tauri dev`
Test checklist:
- [ ] Dark mode is lighter, warm tones preserved
- [ ] Custom titlebar buttons work (minimize, maximize, close)
- [ ] Window dragging works via TopBar
- [ ] Page transitions slide between board list and board view
- [ ] Board cards stagger in on the board list
- [ ] Columns stagger in when opening a board
- [ ] Cards stagger in within columns
- [ ] Card hover scales up slightly with shadow
- [ ] Card tap scales down
- [ ] Clicking card morphs into detail modal (shared layout animation)
- [ ] Closing modal morphs back to card position
- [ ] Drag overlay lifts card with scale + shadow
- [ ] Dragging card tilts based on movement direction
- [ ] Toast notifications bounce in with wobbly spring
- [ ] Settings tab switching crossfades
- [ ] Accent color swatches bounce on hover
- [ ] Shortcut help groups stagger in
- [ ] Saving status fades in/out in TopBar
**Step 4: Commit any polish fixes**
```bash
git add -A
git commit -m "fix: polish pass — animation tweaks and reduced-motion compliance"
```
---
## Quick Reference: File → Task Mapping
| File | Tasks |
|------|-------|
| `src/lib/motion.ts` | 1 (create) |
| `src/index.css` | 2 |
| `src-tauri/tauri.conf.json` | 3 |
| `src/components/layout/WindowControls.tsx` | 3 (create) |
| `src/components/layout/TopBar.tsx` | 3, 9 |
| `src/App.tsx` | 4 |
| `src/components/boards/BoardList.tsx` | 5 |
| `src/components/boards/BoardCard.tsx` | 5 |
| `src/components/board/BoardView.tsx` | 6, 8 |
| `src/components/board/KanbanColumn.tsx` | 6 |
| `src/components/board/CardThumbnail.tsx` | 6, 7 |
| `src/components/card-detail/CardDetailModal.tsx` | 7 |
| `src/components/board/DragOverlayContent.tsx` | 8 |
| `src/components/toast/ToastContainer.tsx` | 10 |
| `src/components/settings/SettingsDialog.tsx` | 11 |
| `src/components/shortcuts/ShortcutHelpModal.tsx` | 12 |