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.
1274 lines
36 KiB
Markdown
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 |
|