From 3703857ccfaaefe7bdfd4d2935e9d23ab87066de Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 15 Feb 2026 20:55:31 +0200 Subject: [PATCH] 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. --- ...motion-darkmode-titlebar-implementation.md | 1273 +++++++++++++++++ 1 file changed, 1273 insertions(+) create mode 100644 docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md diff --git a/docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md b/docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md new file mode 100644 index 0000000..9a5a899 --- /dev/null +++ b/docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md @@ -0,0 +1,1273 @@ +# 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 `