Files
openpylon/docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md
Your Name 940c10336e 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

36 KiB

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:

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

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:

.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

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:

"windows": [
  {
    "title": "OpenPylon",
    "width": 1200,
    "height": 800,
    "minWidth": 800,
    "minHeight": 600,
    "decorations": false
  }
]

Step 2: Create WindowControls component

Create src/components/layout/WindowControls.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:

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:

        <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

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:

import { AnimatePresence, motion } from "framer-motion";
import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion";

Replace line 76 (the view conditional):

{view.type === "board-list" ? <BoardList /> : <BoardView />}

With:

<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

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:

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:

<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):

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

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

import { fadeSlideUp, springs, subtleHover } from "@/lib/motion";

Replace the existing <motion.div> wrapper (lines 76-80):

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

<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

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:

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

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

import { motion, useReducedMotion } from "framer-motion";

Add:

import { fadeSlideUp, springs, staggerContainer } from "@/lib/motion";

Replace the <motion.section> props (lines 66-74):

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

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

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

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

import { fadeSlideUp, springs, subtleHover } from "@/lib/motion";

Replace the <motion.button> props (lines 49-56):

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

<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

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:

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:

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:

  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:

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:

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

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

To:

<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

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:

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:

import { AnimatePresence } from "framer-motion";

Wrap the <DragOverlay> contents with AnimatePresence. Replace lines 382-388:

<DragOverlay>
  {activeCard ? (
    <CardOverlay card={activeCard} boardLabels={board.labels} />
  ) : activeColumn ? (
    <ColumnOverlay column={activeColumn} />
  ) : null}
</DragOverlay>

With:

<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

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:

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):

{savingStatus && (
  <span className="mr-2 font-mono text-xs text-pylon-text-secondary">
    {savingStatus}
  </span>
)}

With AnimatePresence for fade in/out:

<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

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:

import { springs } from "@/lib/motion";

Replace the <motion.div> transition (line 22):

transition={{ type: "spring", stiffness: 400, damping: 25 }}

With:

transition={springs.wobbly}

Also update the exit to slide down:

exit={{ opacity: 0, y: 20, scale: 0.9 }}

Step 2: Commit

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:

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:

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

<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

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:

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:

<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

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

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