-```
-
-Also add a small progress bar under the header. Change lines 39-50 (the header section) to:
-
-```tsx
- {/* Header + progress */}
-
-
-
- Checklist
-
- {checklist.length > 0 && (
-
- {checked}/{checklist.length}
-
- )}
-
- {checklist.length > 0 && (
-
- )}
-
-```
-
-**Step 2: Verify TypeScript compiles**
-
-Run: `npx tsc --noEmit`
-Expected: No errors
-
-**Step 3: Commit**
-
-```bash
-git add src/components/card-detail/ChecklistSection.tsx
-git commit -m "feat: add scroll containment and progress bar to checklist"
-```
-
----
-
-### Task 4: Remove unused Separator import + visual verification
-
-**Files:**
-- Modify: `src/components/card-detail/CardDetailModal.tsx` (verify no stale imports)
-
-**Step 1: Verify the file has no unused imports**
-
-The rewrite in Task 1 already removed the `Separator` import. Confirm the import block does NOT include:
-- `import { Separator } from "@/components/ui/separator";`
-
-If it's still there, delete it.
-
-**Step 2: Run TypeScript check**
-
-Run: `npx tsc --noEmit`
-Expected: No errors
-
-**Step 3: Run the dev server and visually verify**
-
-Run: `npm run dev` (or `npx tauri dev` if checking in Tauri)
-
-Verify:
-- Card detail modal opens on card click
-- Full-width header shows cover color (or neutral bg)
-- Title is editable (click to edit, Enter/Escape)
-- Close button [x] works
-- 2x3 grid: Labels | Due Date / Checklist | Description / Cover | Attachments
-- Each cell has rounded-lg background
-- Checklist scrolls when > ~6 items
-- Description shows compact preview
-- All animations stagger in
-- Escape closes modal
-- Click outside closes modal
-
-**Step 4: Commit**
-
-```bash
-git add -A
-git commit -m "feat: card detail modal dashboard grid redesign complete"
-```
diff --git a/docs/plans/2026-02-15-custom-date-picker-design.md b/docs/plans/2026-02-15-custom-date-picker-design.md
deleted file mode 100644
index 376bf5f..0000000
--- a/docs/plans/2026-02-15-custom-date-picker-design.md
+++ /dev/null
@@ -1,87 +0,0 @@
-# Custom Date Picker — Design Document
-
-**Date:** 2026-02-15
-**Status:** Approved
-
-## Problem
-
-The DueDatePicker uses a native `
` which looks out of place in the app's custom dark theme with OKLCH colors. Need a fully custom calendar widget that matches the design language.
-
-## Design Decisions
-
-| Question | Answer |
-|----------|--------|
-| Trigger | Click the entire due date grid cell |
-| Calendar position | Popover floating below the cell |
-| Navigation | Month + year dropdown selectors |
-| Today button | Yes, at bottom of calendar |
-| Past dates | Selectable but dimmed |
-| Clear action | Both: x on cell display AND Clear button in calendar footer |
-| Approach | Fully custom (date-fns + Radix Popover, no new deps) |
-
-## Layout
-
-```
-┌─ Due Date Grid Cell ──────────────────────┐
-│ DUE DATE [×] │
-│ Feb 20, 2026 · in 5 days │
-└───────────────────────────────────────────┘
- │
- ▼ popover (280px wide)
-┌───────────────────────────────────────┐
-│ ◀ [February ▾] [2026 ▾] ▶ │
-├───────────────────────────────────────┤
-│ Mo Tu We Th Fr Sa Su │
-│ ·· ·· ·· ·· ·· 1 2 │
-│ 3 4 5 6 7 8 9 │
-│ 10 11 12 13 14 ⬤15 16 │
-│ 17 18 19 ■20 21 22 23 │
-│ 24 25 26 27 28 ·· ·· │
-├───────────────────────────────────────┤
-│ [Today] [Clear] │
-└───────────────────────────────────────┘
-```
-
-## Components
-
-### DueDatePicker (modified)
-- Remove `
` entirely
-- Cell display: formatted date + relative time, or placeholder
-- × clear button in section header (visible when date is set)
-- Clicking cell body opens CalendarPopover
-- Overdue dates in `text-pylon-danger`
-
-### CalendarPopover (new)
-- Radix Popover anchored below the cell
-- 280px wide, `bg-pylon-surface rounded-xl shadow-2xl`
-
-#### Header
-- Left/right arrow buttons for prev/next month
-- Clickable month name → month selector (3×4 grid of month names)
-- Clickable year → year selector (grid of years, current ±5)
-
-#### Day Grid
-- 7 columns (Mo-Su), 6 rows max
-- Day cells: `size-9 text-sm rounded-lg`
-- Selected: `bg-pylon-accent text-white`
-- Today: `ring-1 ring-pylon-accent`
-- Past: `opacity-50`
-- Other month days: hidden (empty cells)
-- Hover: `bg-pylon-column`
-
-#### Footer
-- "Today" button (left) — jumps to and selects today
-- "Clear" button (right) — removes due date
-
-### Animation
-- Popover: `scaleIn` + `springs.snappy`
-- Month/year selector: `AnimatePresence mode="wait"` crossfade
-
-## Files
-- Create: `src/components/card-detail/CalendarPopover.tsx`
-- Modify: `src/components/card-detail/DueDatePicker.tsx`
-
-## Dependencies
-- date-fns v4 (already installed): `startOfMonth`, `endOfMonth`, `startOfWeek`, `endOfWeek`, `eachDayOfInterval`, `format`, `isSameDay`, `isSameMonth`, `isToday`, `isPast`, `addMonths`, `subMonths`, `setMonth`, `setYear`, `getYear`
-- Radix Popover (already installed via `src/components/ui/popover.tsx`)
-- Framer Motion (already installed)
diff --git a/docs/plans/2026-02-15-custom-date-picker-implementation.md b/docs/plans/2026-02-15-custom-date-picker-implementation.md
deleted file mode 100644
index b269d4f..0000000
--- a/docs/plans/2026-02-15-custom-date-picker-implementation.md
+++ /dev/null
@@ -1,456 +0,0 @@
-# Custom Date Picker — Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Replace the native HTML date input with a fully custom calendar widget that matches the app's dark OKLCH theme.
-
-**Architecture:** Create a new `CalendarPopover` component (calendar grid + month/year selectors + footer) using date-fns for date math and Radix Popover for positioning. Rewrite `DueDatePicker` to use it instead of `
`. No new dependencies.
-
-**Tech Stack:** React 19, TypeScript, date-fns v4, Framer Motion 12, Tailwind 4, Radix Popover
-
----
-
-### Task 1: Create CalendarPopover component
-
-**Files:**
-- Create: `src/components/card-detail/CalendarPopover.tsx`
-
-**Step 1: Create the file with the complete component**
-
-Create `src/components/card-detail/CalendarPopover.tsx` with:
-
-```tsx
-import { useState, useMemo } from "react";
-import { AnimatePresence, motion } from "framer-motion";
-import { ChevronLeft, ChevronRight } from "lucide-react";
-import {
- startOfMonth,
- endOfMonth,
- startOfWeek,
- endOfWeek,
- eachDayOfInterval,
- format,
- isSameDay,
- isSameMonth,
- isToday as isTodayFn,
- isPast,
- addMonths,
- subMonths,
- setMonth,
- setYear,
- getYear,
- getMonth,
-} from "date-fns";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { Button } from "@/components/ui/button";
-import { springs } from "@/lib/motion";
-
-interface CalendarPopoverProps {
- selectedDate: Date | null;
- onSelect: (date: Date) => void;
- onClear: () => void;
- children: React.ReactNode;
-}
-
-type ViewMode = "days" | "months" | "years";
-
-const MONTH_NAMES = [
- "Jan", "Feb", "Mar", "Apr", "May", "Jun",
- "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
-];
-
-const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
-
-export function CalendarPopover({
- selectedDate,
- onSelect,
- onClear,
- children,
-}: CalendarPopoverProps) {
- const [open, setOpen] = useState(false);
- const [viewDate, setViewDate] = useState(() => selectedDate ?? new Date());
- const [viewMode, setViewMode] = useState
("days");
-
- // Reset view when opening
- function handleOpenChange(nextOpen: boolean) {
- if (nextOpen) {
- setViewDate(selectedDate ?? new Date());
- setViewMode("days");
- }
- setOpen(nextOpen);
- }
-
- function handleSelectDate(date: Date) {
- onSelect(date);
- setOpen(false);
- }
-
- function handleToday() {
- const today = new Date();
- onSelect(today);
- setOpen(false);
- }
-
- function handleClear() {
- onClear();
- setOpen(false);
- }
-
- // Build the 6×7 grid of days for the current viewDate month
- const calendarDays = useMemo(() => {
- const monthStart = startOfMonth(viewDate);
- const monthEnd = endOfMonth(viewDate);
- const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
- const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
- return eachDayOfInterval({ start: gridStart, end: gridEnd });
- }, [viewDate]);
-
- // Year range for year selector: current year ± 5
- const yearRange = useMemo(() => {
- const center = getYear(viewDate);
- const years: number[] = [];
- for (let y = center - 5; y <= center + 5; y++) {
- years.push(y);
- }
- return years;
- }, [viewDate]);
-
- return (
-
- {children}
-
- {/* Navigation header */}
-
-
setViewDate((d) => subMonths(d, 1))}
- className="text-pylon-text-secondary hover:text-pylon-text"
- >
-
-
-
-
- setViewMode(viewMode === "months" ? "days" : "months")}
- className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
- >
- {format(viewDate, "MMMM")}
-
- setViewMode(viewMode === "years" ? "days" : "years")}
- className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
- >
- {format(viewDate, "yyyy")}
-
-
-
-
setViewDate((d) => addMonths(d, 1))}
- className="text-pylon-text-secondary hover:text-pylon-text"
- >
-
-
-
-
- {/* Body: days / months / years */}
-
-
- {viewMode === "days" && (
-
- {/* Weekday headers */}
-
- {WEEKDAYS.map((wd) => (
-
- {wd}
-
- ))}
-
-
- {/* Day grid */}
-
- {calendarDays.map((day) => {
- const inMonth = isSameMonth(day, viewDate);
- const today = isTodayFn(day);
- const selected = selectedDate != null && isSameDay(day, selectedDate);
- const past = isPast(day) && !today;
-
- if (!inMonth) {
- return
;
- }
-
- return (
-
handleSelectDate(day)}
- className={`flex h-9 items-center justify-center rounded-lg text-sm transition-colors
- ${selected
- ? "bg-pylon-accent font-medium text-white"
- : today
- ? "font-medium text-pylon-accent ring-1 ring-pylon-accent"
- : past
- ? "text-pylon-text opacity-50 hover:bg-pylon-column hover:opacity-75"
- : "text-pylon-text hover:bg-pylon-column"
- }`}
- >
- {format(day, "d")}
-
- );
- })}
-
-
- )}
-
- {viewMode === "months" && (
-
- {MONTH_NAMES.map((name, i) => (
- {
- setViewDate((d) => setMonth(d, i));
- setViewMode("days");
- }}
- className={`rounded-lg py-2 text-sm transition-colors ${
- getMonth(viewDate) === i
- ? "bg-pylon-accent font-medium text-white"
- : "text-pylon-text hover:bg-pylon-column"
- }`}
- >
- {name}
-
- ))}
-
- )}
-
- {viewMode === "years" && (
-
- {yearRange.map((year) => (
- {
- setViewDate((d) => setYear(d, year));
- setViewMode("days");
- }}
- className={`rounded-lg py-2 text-sm transition-colors ${
- getYear(viewDate) === year
- ? "bg-pylon-accent font-medium text-white"
- : "text-pylon-text hover:bg-pylon-column"
- }`}
- >
- {year}
-
- ))}
-
- )}
-
-
-
- {/* Footer */}
-
-
- Today
-
-
- Clear
-
-
-
-
- );
-}
-```
-
-**Step 2: Verify TypeScript compiles**
-
-Run: `npx tsc --noEmit`
-Expected: No errors
-
-**Step 3: Commit**
-
-```bash
-git add src/components/card-detail/CalendarPopover.tsx
-git commit -m "feat: create custom CalendarPopover component"
-```
-
----
-
-### Task 2: Rewrite DueDatePicker to use CalendarPopover
-
-**Files:**
-- Modify: `src/components/card-detail/DueDatePicker.tsx` (full rewrite)
-
-**Step 1: Replace the entire file**
-
-Replace `src/components/card-detail/DueDatePicker.tsx` with:
-
-```tsx
-import { format, formatDistanceToNow, isPast, isToday } from "date-fns";
-import { X } from "lucide-react";
-import { useBoardStore } from "@/stores/board-store";
-import { CalendarPopover } from "@/components/card-detail/CalendarPopover";
-
-interface DueDatePickerProps {
- cardId: string;
- dueDate: string | null;
-}
-
-export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) {
- const updateCard = useBoardStore((s) => s.updateCard);
-
- const dateObj = dueDate ? new Date(dueDate) : null;
- const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj);
-
- function handleSelect(date: Date) {
- updateCard(cardId, { dueDate: format(date, "yyyy-MM-dd") });
- }
-
- function handleClear() {
- updateCard(cardId, { dueDate: null });
- }
-
- return (
-
- {/* Header with clear button */}
-
-
- Due Date
-
- {dueDate && (
-
-
-
- )}
-
-
- {/* Clickable date display → opens calendar */}
-
-
- {dateObj ? (
- <>
-
- {format(dateObj, "MMM d, yyyy")}
-
-
- {overdue
- ? `overdue by ${formatDistanceToNow(dateObj)}`
- : isToday(dateObj)
- ? "today"
- : `in ${formatDistanceToNow(dateObj)}`}
-
- >
- ) : (
-
- Click to set date...
-
- )}
-
-
-
- );
-}
-```
-
-**Step 2: Verify TypeScript compiles**
-
-Run: `npx tsc --noEmit`
-Expected: No errors
-
-**Step 3: Commit**
-
-```bash
-git add src/components/card-detail/DueDatePicker.tsx
-git commit -m "feat: rewrite DueDatePicker to use custom CalendarPopover"
-```
-
----
-
-### Task 3: Visual verification and final commit
-
-**Step 1: Run TypeScript check**
-
-Run: `npx tsc --noEmit`
-Expected: No errors
-
-**Step 2: Run the dev server**
-
-Run: `npx tauri dev`
-
-Verify:
-- Open a card → Due Date cell shows "Click to set date..." or the current date
-- Click the cell → calendar popover appears below
-- Calendar shows correct month with today highlighted (ring)
-- Click a date → it's selected (filled accent), popover closes, cell shows formatted date
-- Click month name → month selector grid appears, click a month → returns to days
-- Click year → year selector grid appears, click a year → returns to days
-- Left/right arrows navigate months
-- "Today" button selects today and closes
-- "Clear" button in popover footer removes the date and closes
-- × button in cell header clears the date without opening calendar
-- Past dates are dimmed but clickable
-- Overdue dates show in red
-
-**Step 3: Commit**
-
-```bash
-git add -A
-git commit -m "feat: custom date picker with calendar popover complete"
-```
diff --git a/docs/plans/2026-02-15-motion-darkmode-titlebar-design.md b/docs/plans/2026-02-15-motion-darkmode-titlebar-design.md
deleted file mode 100644
index 064b0f1..0000000
--- a/docs/plans/2026-02-15-motion-darkmode-titlebar-design.md
+++ /dev/null
@@ -1,188 +0,0 @@
-# Motion, Dark Mode & Custom Titlebar Design
-
-**Date:** 2026-02-15
-**Goal:** Add playful bouncy animations everywhere, lighten dark mode for HDR monitors, and implement custom window decorations merged into the TopBar
-**Approach:** Centralized motion system + CSS variable tuning + Tauri decoration override
-
----
-
-## 1. Motion System Foundation
-
-Create `src/lib/motion.ts` — shared animation config imported by all components.
-
-### Spring Presets (Bouncy Profile)
-- **bouncy** — stiffness: 400, damping: 15, mass: 0.8 (main preset, visible overshoot)
-- **snappy** — stiffness: 500, damping: 20 (micro-interactions — buttons, toggles)
-- **gentle** — stiffness: 200, damping: 20 (larger elements — modals, page transitions)
-- **wobbly** — stiffness: 300, damping: 10 (playful emphasis — toasts, notifications)
-
-### Reusable Variants
-- `fadeSlideUp` — enters from below with opacity fade (cards, list items)
-- `fadeSlideDown` — enters from above (dropdowns, menus)
-- `scaleIn` — scales from 0.9 to 1 with bounce (modals, popovers)
-- `staggerContainer` — parent variant that staggers children
-
-### Stagger Helper
-`staggerChildren(delay = 0.04)` — generates parent transition variants for cascading entrances.
-
----
-
-## 2. Component-by-Component Motion Rollout
-
-### Page Transitions (App.tsx)
-- Wrap view switch in `AnimatePresence mode="wait"`
-- Board-list exits with fade+slide-left, board enters with fade+slide-right
-- Uses `gentle` spring
-
-### Board List (BoardList.tsx)
-- Board cards stagger in on mount using `staggerContainer`
-- Each BoardCard uses `fadeSlideUp` entrance
-- Empty state fades in
-
-### Board View (BoardView.tsx)
-- Columns stagger in from left to right on mount (0.06s delay each)
-- Cards within each column stagger in (0.03s delay)
-
-### Card Thumbnails (CardThumbnail.tsx)
-- Migrate existing spring to shared `bouncy` preset
-- `whileHover` scale 1.02 with shadow elevation
-- `whileTap` scale 0.98
-
-### Card Detail Modal (CardDetailModal.tsx)
-- **Shared layout animation** — CardThumbnail gets `layoutId={card-${card.id}}`, modal wrapper gets same layoutId
-- Card morphs into the modal on open — hero transition
-- Backdrop blurs in with animated opacity + backdropFilter
-- Modal content sections stagger in after layout animation
-
-### Column Header (ColumnHeader.tsx)
-- Dropdown menu items stagger in with `fadeSlideDown`
-
-### TopBar
-- Buttons have `whileHover` and `whileTap` micro-animations
-- Saving status text fades in/out with AnimatePresence
-
-### Toast Notifications (ToastContainer.tsx)
-- Migrate to `wobbly` spring for extra personality
-- Exit slides down + fades
-
-### Settings Dialog
-- Tab content crossfades with AnimatePresence
-- Accent color swatches have `whileHover` scale pulse
-
-### Command Palette
-- Results stagger in as you type
-
----
-
-## 3. Gesture-Reactive Drag & Drop
-
-Override dnd-kit's default drag overlay with Framer Motion-powered custom overlay.
-
-- **On drag start:** Card lifts with `scale: 1.05`, box-shadow, slight rotate based on grab offset
-- **During drag:** Card tilts based on pointer velocity (useMotionValue + useTransform). Max tilt: ~5 degrees
-- **On drop:** Spring back to `scale: 1, rotate: 0`. Target column cards spring apart using `layout` prop
-- dnd-kit handles position/sorting logic; we layer gesture transforms on top
-
----
-
-## 4. Dark Mode — Subtle Lift for HDR
-
-### Pylon Dark Variables (in `.dark {}`)
-
-| Variable | Current | New |
-|----------|---------|-----|
-| `--pylon-bg` | `oklch(18% 0.01 50)` | `oklch(25% 0.012 50)` |
-| `--pylon-surface` | `oklch(22% 0.01 50)` | `oklch(29% 0.012 50)` |
-| `--pylon-column` | `oklch(20% 0.012 50)` | `oklch(27% 0.014 50)` |
-| `--pylon-text` | `oklch(90% 0.01 50)` | `oklch(92% 0.01 50)` |
-| `--pylon-text-secondary` | `oklch(55% 0.01 50)` | `oklch(58% 0.01 50)` |
-| `--pylon-accent` | `oklch(60% 0.12 160)` | `oklch(62% 0.13 160)` |
-| `--pylon-danger` | `oklch(60% 0.18 25)` | `oklch(62% 0.18 25)` |
-
-### Shadcn Dark Variables
-
-| Variable | Current | New |
-|----------|---------|-----|
-| `--background` | `oklch(0.145 0 0)` | `oklch(0.22 0 0)` |
-| `--card`, `--popover` | `oklch(0.205 0 0)` | `oklch(0.27 0 0)` |
-| `--secondary`, `--muted`, `--accent` | `oklch(0.269 0 0)` | `oklch(0.32 0 0)` |
-| `--border` | `oklch(1 0 0 / 10%)` | `oklch(1 0 0 / 12%)` |
-| `--input` | `oklch(1 0 0 / 15%)` | `oklch(1 0 0 / 18%)` |
-| `--sidebar` | `oklch(0.205 0 0)` | `oklch(0.27 0 0)` |
-| `--sidebar-accent` | `oklch(0.269 0 0)` | `oklch(0.32 0 0)` |
-
-Color scheme (hue 50 warmth) preserved. Slight chroma bump for HDR vibrancy.
-
----
-
-## 5. Custom Window Titlebar
-
-### Tauri Configuration
-Set `"decorations": false` in `tauri.conf.json` to remove native OS titlebar.
-
-### TopBar Integration
-Window controls added to far right of existing TopBar, after a thin vertical separator:
-
-```
-[Back] .......... [Board Title] .......... [Undo][Redo][Settings][Save][Search][Gear] | [—][□][×]
-```
-
-### WindowControls Component
-Inline in TopBar or extracted to `src/components/layout/WindowControls.tsx`.
-
-**Buttons:**
-- Minimize: `Minus` icon from Lucide → `getCurrentWindow().minimize()`
-- Maximize/Restore: `Square` / `Copy` icon → `getCurrentWindow().toggleMaximize()`
-- Close: `X` icon → `getCurrentWindow().close()`
-
-**Styling:**
-- 32x32px hit area, 16x16px icons
-- Default: `text-pylon-text-secondary`
-- Hover: Background with accent at 10% opacity
-- Close hover: `pylon-danger` at 15% opacity (red highlight — convention)
-- All have Framer Motion `whileHover` and `whileTap` springs
-
-**State tracking:**
-- Listen to `getCurrentWindow().onResized()` for maximize state
-- Query `isMaximized()` on mount for initial icon
-- `data-tauri-drag-region` stays on header; window buttons do NOT propagate drag
-
----
-
-## Files Affected
-
-### New Files
-- `src/lib/motion.ts` — shared spring presets, variants, helpers
-
-### Modified Files
-- `src/App.tsx` — AnimatePresence page transitions
-- `src/index.css` — dark mode color value updates
-- `src/components/layout/TopBar.tsx` — window controls, motion on buttons
-- `src/components/layout/AppShell.tsx` — support page transition wrapper
-- `src/components/boards/BoardList.tsx` — stagger animation on board cards
-- `src/components/boards/BoardCard.tsx` — fadeSlideUp entrance, hover/tap
-- `src/components/board/BoardView.tsx` — column stagger, gesture-reactive drag overlay
-- `src/components/board/KanbanColumn.tsx` — card stagger, layout animation for reorder
-- `src/components/board/CardThumbnail.tsx` — shared layoutId, bouncy preset, hover/tap
-- `src/components/board/ColumnHeader.tsx` — dropdown animation
-- `src/components/card-detail/CardDetailModal.tsx` — shared layout animation (hero), content stagger
-- `src/components/toast/ToastContainer.tsx` — wobbly spring
-- `src/components/settings/SettingsDialog.tsx` — tab crossfade, swatch hover
-- `src/components/command-palette/CommandPalette.tsx` — result stagger
-- `src/components/shortcuts/ShortcutHelpModal.tsx` — entrance animation
-- `src-tauri/tauri.conf.json` — decorations: false
-
-## Implementation Order
-
-1. Motion system foundation (`src/lib/motion.ts`)
-2. Dark mode CSS variable updates
-3. Custom titlebar (Tauri config + WindowControls)
-4. Page transitions (App.tsx + AnimatePresence)
-5. Board list animations (stagger + BoardCard)
-6. Board view column stagger + card stagger
-7. Card thumbnail hover/tap + shared layoutId
-8. Card detail modal shared layout animation
-9. Gesture-reactive drag overlay
-10. Micro-interactions (TopBar, ColumnHeader dropdowns)
-11. Toast, Settings, Command Palette, ShortcutHelp animations
-12. Polish pass — verify all springs feel cohesive, test reduced-motion
diff --git a/docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md b/docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md
deleted file mode 100644
index 9a5a899..0000000
--- a/docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md
+++ /dev/null
@@ -1,1273 +0,0 @@
-# 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.
-
-
-
-
setDialogOpen(true)}>
-
- Create Board
-
-
-
-
-```
-
-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 `` in the right section with `motion` micro-interactions. Since the `` component uses `asChild` pattern with Radix Tooltip, we wrap the `` itself. The simplest approach: convert each bare `` to a `motion.div` wrapper with `whileHover` and `whileTap`.
-
-Replace the saving status span (lines 201-204):
-
-```tsx
-{savingStatus && (
-
- {savingStatus}
-
-)}
-```
-
-With AnimatePresence for fade in/out:
-
-```tsx
-
- {savingStatus && (
-
- {savingStatus}
-
- )}
-
-```
-
-**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 `` 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, ``) with AnimatePresence:
-
-```tsx
-
-
- {/* ... existing tab content unchanged ... */}
-
-
-```
-
-For the accent color swatch buttons (line 196-210), add `motion.button` with hover animation. Replace each `
` in the ACCENT_PRESETS map:
-
-```tsx
- 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 `` (line 45) with a motion stagger container:
-
-```tsx
-
- {SHORTCUT_GROUPS.map((group) => (
-
-
- {group.category}
-
-
- {group.shortcuts.map(({ key, description }) => (
-
- {description}
-
- {key}
-
-
- ))}
-
-
- ))}
-
-```
-
-**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 |
diff --git a/docs/plans/2026-02-15-openpylon-implementation.md b/docs/plans/2026-02-15-openpylon-implementation.md
deleted file mode 100644
index c4fe890..0000000
--- a/docs/plans/2026-02-15-openpylon-implementation.md
+++ /dev/null
@@ -1,1891 +0,0 @@
-# OpenPylon Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Build a local-first Kanban board desktop app using Tauri v2 + React + TypeScript that saves to local JSON files.
-
-**Architecture:** Monolithic Zustand store per board, loaded from JSON on open, debounced writes on mutation. Tauri v2 handles filesystem access. React + shadcn/ui + Tailwind for UI, dnd-kit for drag-and-drop.
-
-**Tech Stack:** Tauri v2, React 19, TypeScript, Zustand, zundo, dnd-kit, shadcn/ui, Tailwind CSS, Framer Motion, Zod, cmdk, ulid
-
----
-
-## Task 1: Scaffold Tauri + React Project
-
-**Files:**
-- Create: entire project scaffold via `create-tauri-app`
-- Modify: `package.json` (add dependencies)
-- Modify: `src-tauri/Cargo.toml` (add plugins)
-- Modify: `src-tauri/capabilities/default.json` (filesystem permissions)
-
-**Step 1: Create the Tauri app**
-
-Run:
-```bash
-cd D:/gdfhbfgdbnbdfbdf/openpylon
-npm create tauri-app@latest . -- --template react-ts --manager npm
-```
-
-If the interactive prompt appears, select:
-- Project name: `openpylon`
-- Frontend: React
-- Language: TypeScript
-- Package manager: npm
-
-**Step 2: Install Tauri filesystem plugin**
-
-Run:
-```bash
-cd D:/gdfhbfgdbnbdfbdf/openpylon
-npm install @tauri-apps/plugin-fs @tauri-apps/plugin-dialog @tauri-apps/plugin-shell
-```
-
-Add the plugin to `src-tauri/Cargo.toml` dependencies:
-```toml
-[dependencies]
-tauri-plugin-fs = "2"
-tauri-plugin-dialog = "2"
-tauri-plugin-shell = "2"
-```
-
-Register plugins in `src-tauri/src/lib.rs`:
-```rust
-pub fn run() {
- tauri::Builder::default()
- .plugin(tauri_plugin_fs::init())
- .plugin(tauri_plugin_dialog::init())
- .plugin(tauri_plugin_shell::init())
- .run(tauri::generate_context!())
- .expect("error while running tauri application");
-}
-```
-
-**Step 3: Configure filesystem permissions**
-
-Modify `src-tauri/capabilities/default.json` to include:
-```json
-{
- "$schema": "../gen/schemas/desktop-schema.json",
- "identifier": "default",
- "description": "Default permissions for OpenPylon",
- "windows": ["main"],
- "permissions": [
- "core:default",
- "dialog:default",
- "shell:default",
- {
- "identifier": "fs:default",
- "allow": [{ "path": "$APPDATA/openpylon/**" }]
- },
- {
- "identifier": "fs:allow-exists",
- "allow": [{ "path": "$APPDATA/openpylon/**" }]
- },
- {
- "identifier": "fs:allow-read",
- "allow": [{ "path": "$APPDATA/openpylon/**" }]
- },
- {
- "identifier": "fs:allow-write",
- "allow": [{ "path": "$APPDATA/openpylon/**" }]
- },
- {
- "identifier": "fs:allow-mkdir",
- "allow": [{ "path": "$APPDATA/openpylon/**" }]
- }
- ]
-}
-```
-
-**Step 4: Install all frontend dependencies**
-
-Run:
-```bash
-npm install zustand zundo @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities framer-motion zod ulid date-fns react-markdown remark-gfm
-```
-
-**Step 5: Install and init shadcn/ui + Tailwind**
-
-Run:
-```bash
-npx shadcn@latest init
-```
-
-Select: TypeScript, default style, base color neutral, CSS variables yes.
-
-Then add required components:
-```bash
-npx shadcn@latest add dialog dropdown-menu tooltip command context-menu popover button input scroll-area badge separator textarea
-```
-
-**Step 6: Verify build**
-
-Run:
-```bash
-npm run tauri dev
-```
-
-Expected: Tauri dev window opens with default React template.
-
-**Step 7: Commit**
-
-```bash
-git add -A
-git commit -m "feat: scaffold Tauri v2 + React + TS project with all dependencies"
-```
-
----
-
-## Task 2: Type Definitions + Zod Schemas
-
-**Files:**
-- Create: `src/types/board.ts`
-- Create: `src/types/settings.ts`
-- Create: `src/lib/schemas.ts`
-
-**Step 1: Create board type definitions**
-
-Create `src/types/board.ts`:
-```typescript
-export interface Board {
- id: string;
- title: string;
- color: string;
- createdAt: string;
- updatedAt: string;
- columns: Column[];
- cards: Record
;
- labels: Label[];
- settings: BoardSettings;
-}
-
-export interface Column {
- id: string;
- title: string;
- cardIds: string[];
- width: ColumnWidth;
-}
-
-export type ColumnWidth = "narrow" | "standard" | "wide";
-
-export interface Card {
- id: string;
- title: string;
- description: string;
- labels: string[];
- checklist: ChecklistItem[];
- dueDate: string | null;
- attachments: Attachment[];
- createdAt: string;
- updatedAt: string;
-}
-
-export interface Label {
- id: string;
- name: string;
- color: string;
-}
-
-export interface ChecklistItem {
- id: string;
- text: string;
- checked: boolean;
-}
-
-export interface Attachment {
- id: string;
- name: string;
- path: string;
- mode: "link" | "copy";
-}
-
-export interface BoardSettings {
- attachmentMode: "link" | "copy";
-}
-
-export interface BoardMeta {
- id: string;
- title: string;
- color: string;
- cardCount: number;
- columnCount: number;
- updatedAt: string;
-}
-```
-
-**Step 2: Create settings types**
-
-Create `src/types/settings.ts`:
-```typescript
-export interface AppSettings {
- theme: "light" | "dark" | "system";
- dataDirectory: string | null;
- recentBoardIds: string[];
-}
-```
-
-**Step 3: Create Zod schemas for validation**
-
-Create `src/lib/schemas.ts`:
-```typescript
-import { z } from "zod";
-
-export const checklistItemSchema = z.object({
- id: z.string(),
- text: z.string(),
- checked: z.boolean(),
-});
-
-export const attachmentSchema = z.object({
- id: z.string(),
- name: z.string(),
- path: z.string(),
- mode: z.enum(["link", "copy"]),
-});
-
-export const labelSchema = z.object({
- id: z.string(),
- name: z.string(),
- color: z.string(),
-});
-
-export const cardSchema = z.object({
- id: z.string(),
- title: z.string(),
- description: z.string().default(""),
- labels: z.array(z.string()).default([]),
- checklist: z.array(checklistItemSchema).default([]),
- dueDate: z.string().nullable().default(null),
- attachments: z.array(attachmentSchema).default([]),
- createdAt: z.string(),
- updatedAt: z.string(),
-});
-
-export const columnSchema = z.object({
- id: z.string(),
- title: z.string(),
- cardIds: z.array(z.string()).default([]),
- width: z.enum(["narrow", "standard", "wide"]).default("standard"),
-});
-
-export const boardSettingsSchema = z.object({
- attachmentMode: z.enum(["link", "copy"]).default("link"),
-});
-
-export const boardSchema = z.object({
- id: z.string(),
- title: z.string(),
- color: z.string().default("#4a9d7f"),
- createdAt: z.string(),
- updatedAt: z.string(),
- columns: z.array(columnSchema).default([]),
- cards: z.record(z.string(), cardSchema).default({}),
- labels: z.array(labelSchema).default([]),
- settings: boardSettingsSchema.default({}),
-});
-
-export const appSettingsSchema = z.object({
- theme: z.enum(["light", "dark", "system"]).default("system"),
- dataDirectory: z.string().nullable().default(null),
- recentBoardIds: z.array(z.string()).default([]),
-});
-```
-
-**Step 4: Commit**
-
-```bash
-git add src/types/ src/lib/schemas.ts
-git commit -m "feat: add board/settings type definitions and Zod validation schemas"
-```
-
----
-
-## Task 3: Filesystem Persistence Layer
-
-**Files:**
-- Create: `src/lib/storage.ts`
-- Create: `src/lib/storage.test.ts`
-
-**Step 1: Create the storage module**
-
-Create `src/lib/storage.ts`:
-```typescript
-import {
- exists,
- mkdir,
- readTextFile,
- writeTextFile,
- readDir,
- remove,
- copyFile,
- BaseDirectory,
-} from "@tauri-apps/plugin-fs";
-import { appDataDir, join } from "@tauri-apps/api/path";
-import { boardSchema, appSettingsSchema } from "./schemas";
-import type { Board, BoardMeta } from "../types/board";
-import type { AppSettings } from "../types/settings";
-
-const APP_DIR_NAME = "openpylon";
-
-async function getAppDir(): Promise {
- const base = await appDataDir();
- return await join(base, APP_DIR_NAME);
-}
-
-async function getBoardsDir(): Promise {
- const appDir = await getAppDir();
- return await join(appDir, "boards");
-}
-
-async function getAttachmentsDir(boardId: string): Promise {
- const appDir = await getAppDir();
- return await join(appDir, "attachments", boardId);
-}
-
-export async function ensureDataDirs(): Promise {
- const appDir = await getAppDir();
- const boardsDir = await getBoardsDir();
- const attachDir = await join(appDir, "attachments");
-
- for (const dir of [appDir, boardsDir, attachDir]) {
- if (!(await exists(dir))) {
- await mkdir(dir, { recursive: true });
- }
- }
-}
-
-export async function loadSettings(): Promise {
- const appDir = await getAppDir();
- const settingsPath = await join(appDir, "settings.json");
-
- if (!(await exists(settingsPath))) {
- const defaults: AppSettings = {
- theme: "system",
- dataDirectory: null,
- recentBoardIds: [],
- };
- await writeTextFile(settingsPath, JSON.stringify(defaults, null, 2));
- return defaults;
- }
-
- const raw = await readTextFile(settingsPath);
- return appSettingsSchema.parse(JSON.parse(raw));
-}
-
-export async function saveSettings(settings: AppSettings): Promise {
- const appDir = await getAppDir();
- const settingsPath = await join(appDir, "settings.json");
- await writeTextFile(settingsPath, JSON.stringify(settings, null, 2));
-}
-
-export async function listBoards(): Promise {
- const boardsDir = await getBoardsDir();
-
- if (!(await exists(boardsDir))) {
- return [];
- }
-
- const entries = await readDir(boardsDir);
- const metas: BoardMeta[] = [];
-
- for (const entry of entries) {
- if (!entry.name?.endsWith(".json") || entry.name.endsWith(".backup.json")) {
- continue;
- }
-
- try {
- const filePath = await join(boardsDir, entry.name);
- const raw = await readTextFile(filePath);
- const board = boardSchema.parse(JSON.parse(raw));
- metas.push({
- id: board.id,
- title: board.title,
- color: board.color,
- cardCount: Object.keys(board.cards).length,
- columnCount: board.columns.length,
- updatedAt: board.updatedAt,
- });
- } catch {
- // Skip corrupted files
- }
- }
-
- return metas.sort(
- (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
- );
-}
-
-export async function loadBoard(boardId: string): Promise {
- const boardsDir = await getBoardsDir();
- const filePath = await join(boardsDir, `board-${boardId}.json`);
- const raw = await readTextFile(filePath);
- return boardSchema.parse(JSON.parse(raw));
-}
-
-export async function saveBoard(board: Board): Promise {
- const boardsDir = await getBoardsDir();
- const filePath = await join(boardsDir, `board-${board.id}.json`);
- const backupPath = await join(boardsDir, `board-${board.id}.backup.json`);
-
- // Rotate current to backup before writing new version
- if (await exists(filePath)) {
- try {
- await copyFile(filePath, backupPath);
- } catch {
- // Backup failure is non-fatal
- }
- }
-
- await writeTextFile(filePath, JSON.stringify(board, null, 2));
-}
-
-export async function deleteBoard(boardId: string): Promise {
- const boardsDir = await getBoardsDir();
- const filePath = await join(boardsDir, `board-${boardId}.json`);
- const backupPath = await join(boardsDir, `board-${boardId}.backup.json`);
-
- if (await exists(filePath)) await remove(filePath);
- if (await exists(backupPath)) await remove(backupPath);
-
- // Remove attachments dir too
- const attachDir = await getAttachmentsDir(boardId);
- if (await exists(attachDir)) await remove(attachDir, { recursive: true });
-}
-
-export async function searchAllBoards(
- query: string
-): Promise> {
- const boardsDir = await getBoardsDir();
- const entries = await readDir(boardsDir);
- const results: Array<{ boardId: string; boardTitle: string; card: { id: string; title: string } }> = [];
- const lowerQuery = query.toLowerCase();
-
- for (const entry of entries) {
- if (!entry.name?.endsWith(".json") || entry.name.endsWith(".backup.json")) {
- continue;
- }
-
- try {
- const filePath = await join(boardsDir, entry.name);
- const raw = await readTextFile(filePath);
- const board = boardSchema.parse(JSON.parse(raw));
-
- for (const card of Object.values(board.cards)) {
- if (
- card.title.toLowerCase().includes(lowerQuery) ||
- card.description.toLowerCase().includes(lowerQuery)
- ) {
- results.push({
- boardId: board.id,
- boardTitle: board.title,
- card: { id: card.id, title: card.title },
- });
- }
- }
- } catch {
- // Skip corrupted files
- }
- }
-
- return results;
-}
-
-export async function restoreFromBackup(boardId: string): Promise {
- const boardsDir = await getBoardsDir();
- const backupPath = await join(boardsDir, `board-${boardId}.backup.json`);
-
- if (!(await exists(backupPath))) return null;
-
- const raw = await readTextFile(backupPath);
- return boardSchema.parse(JSON.parse(raw));
-}
-```
-
-**Step 2: Commit**
-
-```bash
-git add src/lib/storage.ts
-git commit -m "feat: add filesystem persistence layer for boards and settings"
-```
-
----
-
-## Task 4: Zustand Stores
-
-**Files:**
-- Create: `src/stores/app-store.ts`
-- Create: `src/stores/board-store.ts`
-
-**Step 1: Create the app store**
-
-Create `src/stores/app-store.ts`:
-```typescript
-import { create } from "zustand";
-import type { AppSettings } from "../types/settings";
-import type { BoardMeta } from "../types/board";
-import { loadSettings, saveSettings, listBoards } from "../lib/storage";
-
-type View = { type: "board-list" } | { type: "board"; boardId: string };
-
-interface AppState {
- settings: AppSettings;
- boards: BoardMeta[];
- view: View;
- initialized: boolean;
-
- init: () => Promise;
- setTheme: (theme: AppSettings["theme"]) => void;
- setView: (view: View) => void;
- refreshBoards: () => Promise;
- addRecentBoard: (boardId: string) => void;
-}
-
-export const useAppStore = create((set, get) => ({
- settings: { theme: "system", dataDirectory: null, recentBoardIds: [] },
- boards: [],
- view: { type: "board-list" },
- initialized: false,
-
- init: async () => {
- const settings = await loadSettings();
- const boards = await listBoards();
- set({ settings, boards, initialized: true });
- applyTheme(settings.theme);
- },
-
- setTheme: (theme) => {
- const settings = { ...get().settings, theme };
- set({ settings });
- saveSettings(settings);
- applyTheme(theme);
- },
-
- setView: (view) => set({ view }),
-
- refreshBoards: async () => {
- const boards = await listBoards();
- set({ boards });
- },
-
- addRecentBoard: (boardId) => {
- const settings = get().settings;
- const recent = [boardId, ...settings.recentBoardIds.filter((id) => id !== boardId)].slice(0, 10);
- const updated = { ...settings, recentBoardIds: recent };
- set({ settings: updated });
- saveSettings(updated);
- },
-}));
-
-function applyTheme(theme: AppSettings["theme"]): void {
- const root = document.documentElement;
- if (theme === "system") {
- const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
- root.classList.toggle("dark", prefersDark);
- } else {
- root.classList.toggle("dark", theme === "dark");
- }
-}
-```
-
-**Step 2: Create the board store with undo/redo**
-
-Create `src/stores/board-store.ts`:
-```typescript
-import { create } from "zustand";
-import { temporal } from "zundo";
-import { ulid } from "ulid";
-import type { Board, Card, Column, Label, ChecklistItem, Attachment, ColumnWidth } from "../types/board";
-import { saveBoard, loadBoard } from "../lib/storage";
-
-interface BoardState {
- board: Board | null;
- saving: boolean;
- lastSaved: number | null;
-}
-
-interface BoardActions {
- // Board lifecycle
- openBoard: (boardId: string) => Promise;
- closeBoard: () => void;
-
- // Column actions
- addColumn: (title: string) => void;
- updateColumnTitle: (columnId: string, title: string) => void;
- deleteColumn: (columnId: string) => void;
- moveColumn: (fromIndex: number, toIndex: number) => void;
- setColumnWidth: (columnId: string, width: ColumnWidth) => void;
-
- // Card actions
- addCard: (columnId: string, title: string) => void;
- updateCard: (cardId: string, updates: Partial) => void;
- deleteCard: (cardId: string) => void;
- moveCard: (cardId: string, fromColumnId: string, toColumnId: string, toIndex: number) => void;
-
- // Label actions
- addLabel: (name: string, color: string) => void;
- updateLabel: (labelId: string, updates: Partial) => void;
- deleteLabel: (labelId: string) => void;
- toggleCardLabel: (cardId: string, labelId: string) => void;
-
- // Checklist actions
- addChecklistItem: (cardId: string, text: string) => void;
- toggleChecklistItem: (cardId: string, itemId: string) => void;
- updateChecklistItem: (cardId: string, itemId: string, text: string) => void;
- deleteChecklistItem: (cardId: string, itemId: string) => void;
-
- // Attachment actions
- addAttachment: (cardId: string, attachment: Omit) => void;
- removeAttachment: (cardId: string, attachmentId: string) => void;
-
- // Board settings
- updateBoardTitle: (title: string) => void;
- updateBoardColor: (color: string) => void;
- updateBoardSettings: (settings: Board["settings"]) => void;
-}
-
-let saveTimeout: ReturnType | null = null;
-
-function debouncedSave(board: Board, set: (state: Partial) => void): void {
- if (saveTimeout) clearTimeout(saveTimeout);
- saveTimeout = setTimeout(async () => {
- set({ saving: true });
- await saveBoard(board);
- set({ saving: false, lastSaved: Date.now() });
- }, 500);
-}
-
-function now(): string {
- return new Date().toISOString();
-}
-
-export const useBoardStore = create()(
- temporal(
- (set, get) => ({
- board: null,
- saving: false,
- lastSaved: null,
-
- openBoard: async (boardId: string) => {
- const board = await loadBoard(boardId);
- set({ board, saving: false, lastSaved: null });
- },
-
- closeBoard: () => {
- // Flush any pending save
- if (saveTimeout) {
- clearTimeout(saveTimeout);
- const { board } = get();
- if (board) saveBoard(board);
- }
- set({ board: null, saving: false, lastSaved: null });
- },
-
- addColumn: (title: string) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = {
- ...board,
- updatedAt: now(),
- columns: [...board.columns, { id: ulid(), title, cardIds: [], width: "standard" }],
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- updateColumnTitle: (columnId, title) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = {
- ...board,
- updatedAt: now(),
- columns: board.columns.map((c) => (c.id === columnId ? { ...c, title } : c)),
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- deleteColumn: (columnId) => {
- const { board } = get();
- if (!board) return;
- const col = board.columns.find((c) => c.id === columnId);
- if (!col) return;
- const newCards = { ...board.cards };
- col.cardIds.forEach((id) => delete newCards[id]);
- const updated: Board = {
- ...board,
- updatedAt: now(),
- columns: board.columns.filter((c) => c.id !== columnId),
- cards: newCards,
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- moveColumn: (fromIndex, toIndex) => {
- const { board } = get();
- if (!board) return;
- const cols = [...board.columns];
- const [moved] = cols.splice(fromIndex, 1);
- cols.splice(toIndex, 0, moved);
- const updated: Board = { ...board, updatedAt: now(), columns: cols };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- setColumnWidth: (columnId, width) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = {
- ...board,
- columns: board.columns.map((c) => (c.id === columnId ? { ...c, width } : c)),
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- addCard: (columnId, title) => {
- const { board } = get();
- if (!board) return;
- const cardId = ulid();
- const card: Card = {
- id: cardId,
- title,
- description: "",
- labels: [],
- checklist: [],
- dueDate: null,
- attachments: [],
- createdAt: now(),
- updatedAt: now(),
- };
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: { ...board.cards, [cardId]: card },
- columns: board.columns.map((c) =>
- c.id === columnId ? { ...c, cardIds: [...c.cardIds, cardId] } : c
- ),
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- updateCard: (cardId, updates) => {
- const { board } = get();
- if (!board || !board.cards[cardId]) return;
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: {
- ...board.cards,
- [cardId]: { ...board.cards[cardId], ...updates, updatedAt: now() },
- },
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- deleteCard: (cardId) => {
- const { board } = get();
- if (!board) return;
- const newCards = { ...board.cards };
- delete newCards[cardId];
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: newCards,
- columns: board.columns.map((c) => ({
- ...c,
- cardIds: c.cardIds.filter((id) => id !== cardId),
- })),
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- moveCard: (cardId, fromColumnId, toColumnId, toIndex) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = {
- ...board,
- updatedAt: now(),
- columns: board.columns.map((c) => {
- if (c.id === fromColumnId && c.id === toColumnId) {
- const ids = c.cardIds.filter((id) => id !== cardId);
- ids.splice(toIndex, 0, cardId);
- return { ...c, cardIds: ids };
- }
- if (c.id === fromColumnId) {
- return { ...c, cardIds: c.cardIds.filter((id) => id !== cardId) };
- }
- if (c.id === toColumnId) {
- const ids = [...c.cardIds];
- ids.splice(toIndex, 0, cardId);
- return { ...c, cardIds: ids };
- }
- return c;
- }),
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- addLabel: (name, color) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = {
- ...board,
- updatedAt: now(),
- labels: [...board.labels, { id: ulid(), name, color }],
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- updateLabel: (labelId, updates) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = {
- ...board,
- updatedAt: now(),
- labels: board.labels.map((l) => (l.id === labelId ? { ...l, ...updates } : l)),
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- deleteLabel: (labelId) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = {
- ...board,
- updatedAt: now(),
- labels: board.labels.filter((l) => l.id !== labelId),
- cards: Object.fromEntries(
- Object.entries(board.cards).map(([id, card]) => [
- id,
- { ...card, labels: card.labels.filter((l) => l !== labelId) },
- ])
- ),
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- toggleCardLabel: (cardId, labelId) => {
- const { board } = get();
- if (!board || !board.cards[cardId]) return;
- const card = board.cards[cardId];
- const labels = card.labels.includes(labelId)
- ? card.labels.filter((l) => l !== labelId)
- : [...card.labels, labelId];
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: { ...board.cards, [cardId]: { ...card, labels, updatedAt: now() } },
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- addChecklistItem: (cardId, text) => {
- const { board } = get();
- if (!board || !board.cards[cardId]) return;
- const card = board.cards[cardId];
- const item: ChecklistItem = { id: ulid(), text, checked: false };
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: {
- ...board.cards,
- [cardId]: { ...card, checklist: [...card.checklist, item], updatedAt: now() },
- },
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- toggleChecklistItem: (cardId, itemId) => {
- const { board } = get();
- if (!board || !board.cards[cardId]) return;
- const card = board.cards[cardId];
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: {
- ...board.cards,
- [cardId]: {
- ...card,
- updatedAt: now(),
- checklist: card.checklist.map((item) =>
- item.id === itemId ? { ...item, checked: !item.checked } : item
- ),
- },
- },
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- updateChecklistItem: (cardId, itemId, text) => {
- const { board } = get();
- if (!board || !board.cards[cardId]) return;
- const card = board.cards[cardId];
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: {
- ...board.cards,
- [cardId]: {
- ...card,
- updatedAt: now(),
- checklist: card.checklist.map((item) =>
- item.id === itemId ? { ...item, text } : item
- ),
- },
- },
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- deleteChecklistItem: (cardId, itemId) => {
- const { board } = get();
- if (!board || !board.cards[cardId]) return;
- const card = board.cards[cardId];
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: {
- ...board.cards,
- [cardId]: {
- ...card,
- updatedAt: now(),
- checklist: card.checklist.filter((item) => item.id !== itemId),
- },
- },
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- addAttachment: (cardId, attachment) => {
- const { board } = get();
- if (!board || !board.cards[cardId]) return;
- const card = board.cards[cardId];
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: {
- ...board.cards,
- [cardId]: {
- ...card,
- updatedAt: now(),
- attachments: [...card.attachments, { ...attachment, id: ulid() }],
- },
- },
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- removeAttachment: (cardId, attachmentId) => {
- const { board } = get();
- if (!board || !board.cards[cardId]) return;
- const card = board.cards[cardId];
- const updated: Board = {
- ...board,
- updatedAt: now(),
- cards: {
- ...board.cards,
- [cardId]: {
- ...card,
- updatedAt: now(),
- attachments: card.attachments.filter((a) => a.id !== attachmentId),
- },
- },
- };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- updateBoardTitle: (title) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = { ...board, title, updatedAt: now() };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- updateBoardColor: (color) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = { ...board, color, updatedAt: now() };
- set({ board: updated });
- debouncedSave(updated, set);
- },
-
- updateBoardSettings: (settings) => {
- const { board } = get();
- if (!board) return;
- const updated: Board = { ...board, settings, updatedAt: now() };
- set({ board: updated });
- debouncedSave(updated, set);
- },
- }),
- {
- limit: 50,
- partialize: (state) => {
- const { board } = state;
- return { board };
- },
- }
- )
-);
-```
-
-**Step 3: Commit**
-
-```bash
-git add src/stores/
-git commit -m "feat: add Zustand stores with undo/redo and debounced persistence"
-```
-
----
-
-## Task 5: Tailwind Theme + Font Setup
-
-**Files:**
-- Modify: `src/index.css` (or Tailwind base file)
-- Modify: `tailwind.config.ts`
-- Create: `src/styles/fonts.css`
-
-**Step 1: Download and set up fonts**
-
-Download Instrument Serif, Satoshi, and Geist Mono as woff2 files and place in `src/assets/fonts/`. Alternatively, use Google Fonts for Instrument Serif and CDN links for Satoshi/Geist Mono.
-
-Create `src/styles/fonts.css`:
-```css
-@font-face {
- font-family: "Instrument Serif";
- src: url("https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap");
- font-display: swap;
-}
-
-/* Satoshi from fontshare.com — self-host the woff2 files */
-@font-face {
- font-family: "Satoshi";
- src: url("../assets/fonts/Satoshi-Variable.woff2") format("woff2");
- font-weight: 300 900;
- font-display: swap;
-}
-
-@font-face {
- font-family: "Geist Mono";
- src: url("../assets/fonts/GeistMono-Variable.woff2") format("woff2");
- font-weight: 100 900;
- font-display: swap;
-}
-```
-
-**Step 2: Configure Tailwind with custom theme**
-
-Update `tailwind.config.ts`:
-```typescript
-import type { Config } from "tailwindcss";
-
-export default {
- darkMode: "class",
- content: ["./index.html", "./src/**/*.{ts,tsx}"],
- theme: {
- extend: {
- fontFamily: {
- heading: ['"Instrument Serif"', "serif"],
- body: ["Satoshi", "sans-serif"],
- mono: ['"Geist Mono"', "monospace"],
- },
- colors: {
- pylon: {
- bg: "oklch(var(--color-bg) / )",
- surface: "oklch(var(--color-surface) / )",
- column: "oklch(var(--color-column) / )",
- accent: "oklch(var(--color-accent) / )",
- text: "oklch(var(--color-text) / )",
- "text-secondary": "oklch(var(--color-text-secondary) / )",
- danger: "oklch(var(--color-danger) / )",
- },
- },
- },
- },
- plugins: [require("tailwindcss-animate")],
-} satisfies Config;
-```
-
-**Step 3: Set CSS custom properties for light/dark**
-
-In `src/index.css` (add to the existing file after Tailwind directives):
-```css
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-@import "./styles/fonts.css";
-
-@layer base {
- :root {
- --color-bg: 97% 0.005 80;
- --color-surface: 99% 0.003 80;
- --color-column: 95% 0.008 80;
- --color-accent: 55% 0.12 160;
- --color-text: 25% 0.015 50;
- --color-text-secondary: 55% 0.01 50;
- --color-danger: 55% 0.18 25;
- }
-
- .dark {
- --color-bg: 18% 0.01 50;
- --color-surface: 22% 0.01 50;
- --color-column: 20% 0.012 50;
- --color-accent: 60% 0.12 160;
- --color-text: 90% 0.01 50;
- --color-text-secondary: 55% 0.01 50;
- --color-danger: 60% 0.18 25;
- }
-
- body {
- font-family: "Satoshi", sans-serif;
- background: oklch(var(--color-bg));
- color: oklch(var(--color-text));
- }
-
- @media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- }
- }
-}
-```
-
-**Step 4: Commit**
-
-```bash
-git add src/styles/ src/index.css tailwind.config.ts src/assets/
-git commit -m "feat: configure custom theme with OKLCH colors, fonts, and dark mode"
-```
-
----
-
-## Task 6: App Shell + Router
-
-**Files:**
-- Modify: `src/App.tsx`
-- Create: `src/components/layout/AppShell.tsx`
-- Create: `src/components/layout/TopBar.tsx`
-
-**Step 1: Create the app shell**
-
-Create `src/components/layout/TopBar.tsx`:
-```typescript
-import { useAppStore } from "../../stores/app-store";
-import { useBoardStore } from "../../stores/board-store";
-import { useState, useRef, useEffect } from "react";
-import { Button } from "../ui/button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
-
-export function TopBar() {
- const { view, setView } = useAppStore();
- const { board, updateBoardTitle, saving, lastSaved } = useBoardStore();
- const [editing, setEditing] = useState(false);
- const [title, setTitle] = useState("");
- const inputRef = useRef(null);
-
- const isBoard = view.type === "board" && board;
-
- useEffect(() => {
- if (editing && inputRef.current) {
- inputRef.current.focus();
- inputRef.current.select();
- }
- }, [editing]);
-
- const handleTitleSave = () => {
- if (title.trim()) updateBoardTitle(title.trim());
- setEditing(false);
- };
-
- return (
-
-
- {isBoard && (
- {
- useBoardStore.getState().closeBoard();
- setView({ type: "board-list" });
- }}
- className="text-pylon-text-secondary hover:text-pylon-text text-sm font-mono"
- >
- ← Boards
-
- )}
-
- {isBoard && !editing && (
-
{
- setTitle(board.title);
- setEditing(true);
- }}
- >
- {board.title}
-
- )}
-
- {isBoard && editing && (
- setTitle(e.target.value)}
- onBlur={handleTitleSave}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleTitleSave();
- if (e.key === "Escape") setEditing(false);
- }}
- className="font-heading text-lg bg-transparent border-b border-pylon-accent outline-none"
- />
- )}
-
- {!isBoard && (
- OpenPylon
- )}
-
-
-
- {saving && (
-
Saving...
- )}
- {!saving && lastSaved && (
-
Saved
- )}
-
-
-
- {
- // Will be wired to command palette
- document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
- }}
- >
- ⌘K
-
-
- Command palette
-
-
-
-
- {
- // Will open settings dialog
- }}
- >
-
-
-
- Settings
-
-
-
- );
-}
-```
-
-**Step 2: Create the app shell**
-
-Create `src/components/layout/AppShell.tsx`:
-```typescript
-import { TopBar } from "./TopBar";
-import { TooltipProvider } from "../ui/tooltip";
-
-export function AppShell({ children }: { children: React.ReactNode }) {
- return (
-
-
-
- {children}
-
-
- );
-}
-```
-
-**Step 3: Wire up App.tsx with routing by view state**
-
-Replace `src/App.tsx`:
-```typescript
-import { useEffect } from "react";
-import { useAppStore } from "./stores/app-store";
-import { AppShell } from "./components/layout/AppShell";
-import { BoardList } from "./components/boards/BoardList";
-import { BoardView } from "./components/board/BoardView";
-import { ensureDataDirs } from "./lib/storage";
-
-export default function App() {
- const { view, initialized, init } = useAppStore();
-
- useEffect(() => {
- ensureDataDirs().then(() => init());
- }, []);
-
- if (!initialized) {
- return (
-
- Loading...
-
- );
- }
-
- return (
-
- {view.type === "board-list" && }
- {view.type === "board" && }
-
- );
-}
-```
-
-**Step 4: Commit**
-
-```bash
-git add src/App.tsx src/components/layout/
-git commit -m "feat: add app shell with top bar, view routing, and inline title editing"
-```
-
----
-
-## Task 7: Board List (Home Screen)
-
-**Files:**
-- Create: `src/components/boards/BoardList.tsx`
-- Create: `src/components/boards/BoardCard.tsx`
-- Create: `src/components/boards/NewBoardDialog.tsx`
-- Create: `src/lib/board-factory.ts`
-
-**Step 1: Create board factory**
-
-Create `src/lib/board-factory.ts`:
-```typescript
-import { ulid } from "ulid";
-import type { Board } from "../types/board";
-
-type Template = "blank" | "kanban" | "sprint";
-
-export function createBoard(title: string, color: string, template: Template = "blank"): Board {
- const now = new Date().toISOString();
- const board: Board = {
- id: ulid(),
- title,
- color,
- createdAt: now,
- updatedAt: now,
- columns: [],
- cards: {},
- labels: [],
- settings: { attachmentMode: "link" },
- };
-
- if (template === "kanban") {
- board.columns = [
- { id: ulid(), title: "To Do", cardIds: [], width: "standard" },
- { id: ulid(), title: "In Progress", cardIds: [], width: "standard" },
- { id: ulid(), title: "Done", cardIds: [], width: "standard" },
- ];
- } else if (template === "sprint") {
- board.columns = [
- { id: ulid(), title: "Backlog", cardIds: [], width: "standard" },
- { id: ulid(), title: "To Do", cardIds: [], width: "standard" },
- { id: ulid(), title: "In Progress", cardIds: [], width: "wide" },
- { id: ulid(), title: "Review", cardIds: [], width: "standard" },
- { id: ulid(), title: "Done", cardIds: [], width: "narrow" },
- ];
- }
-
- return board;
-}
-```
-
-**Step 2: Create BoardCard, NewBoardDialog, and BoardList components**
-
-These render the home screen grid of boards, the "New Board" dialog with template selection, and the board card with color stripe + metadata. Use shadcn Dialog for NewBoardDialog, context menu for right-click actions. Display relative time with `date-fns` `formatDistanceToNow`.
-
-**Step 3: Wire up board opening**
-
-On board card click: call `useBoardStore.getState().openBoard(boardId)`, then `useAppStore.getState().setView({ type: "board", boardId })` and `useAppStore.getState().addRecentBoard(boardId)`.
-
-**Step 4: Commit**
-
-```bash
-git add src/components/boards/ src/lib/board-factory.ts
-git commit -m "feat: add board list home screen with templates, context menu, and relative time"
-```
-
----
-
-## Task 8: Board View — Columns
-
-**Files:**
-- Create: `src/components/board/BoardView.tsx`
-- Create: `src/components/board/KanbanColumn.tsx`
-- Create: `src/components/board/AddCardInput.tsx`
-- Create: `src/components/board/ColumnHeader.tsx`
-
-**Step 1: Build the board view container**
-
-`BoardView` — horizontal flex container with `overflow-x-auto`, gap of `1.5rem` (24px) between columns. Each column scrolls vertically independently (`overflow-y-auto`). A "+" button at the end adds new columns via inline input.
-
-**Step 2: Build column component**
-
-`KanbanColumn` — renders the column header (uppercase, tracked, monospace count), card list, and "Add card" button. Column background uses `bg-pylon-column` with rounded corners. Width varies by column `width` setting: narrow (180px), standard (280px), wide (360px).
-
-`ColumnHeader` — shows title, card count, and a dropdown menu (rename, delete, change width). Double-click title to cycle width.
-
-`AddCardInput` — click "+ Add card" to reveal an inline text input. Enter to create, Escape to cancel.
-
-**Step 3: Commit**
-
-```bash
-git add src/components/board/
-git commit -m "feat: add board view with columns, headers, and inline card creation"
-```
-
----
-
-## Task 9: Card Thumbnails
-
-**Files:**
-- Create: `src/components/board/CardThumbnail.tsx`
-- Create: `src/components/board/LabelDots.tsx`
-- Create: `src/components/board/ChecklistBar.tsx`
-
-**Step 1: Build card thumbnail**
-
-`CardThumbnail` — renders card title, label dots (8px colored circles), due date (monospace, right-aligned, terracotta if overdue), and checklist progress bar (tiny filled/unfilled blocks). No borders — shadow only (`shadow-sm`). Hover: `translateY(-1px)` with shadow deepening.
-
-`LabelDots` — maps card label IDs to board labels, renders 8px circles with tooltip on hover showing label name.
-
-`ChecklistBar` — renders N small blocks, filled for checked items. Uses proportional width blocks.
-
-**Step 2: Commit**
-
-```bash
-git add src/components/board/CardThumbnail.tsx src/components/board/LabelDots.tsx src/components/board/ChecklistBar.tsx
-git commit -m "feat: add card thumbnails with label dots, due dates, and checklist progress bar"
-```
-
----
-
-## Task 10: Drag and Drop
-
-**Files:**
-- Modify: `src/components/board/BoardView.tsx` (wrap with DndContext)
-- Modify: `src/components/board/KanbanColumn.tsx` (make droppable + sortable)
-- Modify: `src/components/board/CardThumbnail.tsx` (make draggable)
-- Create: `src/components/board/DragOverlay.tsx`
-
-**Step 1: Set up DndContext in BoardView**
-
-Wrap the board view with dnd-kit's `DndContext` + `SortableContext` for columns. Use `PointerSensor` and `KeyboardSensor`. Handle `onDragStart`, `onDragOver`, `onDragEnd` to distinguish card moves vs column reorders.
-
-**Step 2: Make cards sortable**
-
-Each card uses `useSortable` from `@dnd-kit/sortable`. Cards are sortable within and across columns. Use `verticalListSortingStrategy` within columns.
-
-**Step 3: Make columns sortable**
-
-Columns use `useSortable` for column-level reordering. Use `horizontalListSortingStrategy`.
-
-**Step 4: Create drag overlay**
-
-`DragOverlay` — renders a styled copy of the dragged card with rotation (5deg), scale (1.03), reduced opacity (0.9), and elevated shadow. Uses Framer Motion for the transform.
-
-**Step 5: Wire up store mutations**
-
-`onDragEnd` calls `moveCard()` or `moveColumn()` from the board store based on what was dragged. Include keyboard announcements via dnd-kit's `screenReaderInstructions` and `announcements`.
-
-**Step 6: Commit**
-
-```bash
-git add src/components/board/
-git commit -m "feat: add drag-and-drop for cards and columns with keyboard support"
-```
-
----
-
-## Task 11: Card Detail Modal
-
-**Files:**
-- Create: `src/components/card-detail/CardDetailModal.tsx`
-- Create: `src/components/card-detail/MarkdownEditor.tsx`
-- Create: `src/components/card-detail/ChecklistSection.tsx`
-- Create: `src/components/card-detail/LabelPicker.tsx`
-- Create: `src/components/card-detail/DueDatePicker.tsx`
-- Create: `src/components/card-detail/AttachmentSection.tsx`
-
-**Step 1: Build the two-panel modal**
-
-`CardDetailModal` — shadcn Dialog with `layoutId` animation via Framer Motion (card-to-modal morph). Left panel (60%): inline-editable title + markdown editor. Right sidebar (40%): collapsible sections for labels, due date, checklist, attachments.
-
-**Step 2: Build markdown editor**
-
-`MarkdownEditor` — textarea with a toggle between edit mode and preview mode. Preview uses `react-markdown` with `remark-gfm` for GitHub-flavored markdown (task lists, tables, strikethrough). Auto-saves on blur via `updateCard`.
-
-**Step 3: Build checklist section**
-
-`ChecklistSection` — list of items with checkboxes. Click to toggle. Inline editing of text. "Add item" input at bottom. Delete via small X button. Progress shown as "N/M" at section header.
-
-**Step 4: Build label picker**
-
-`LabelPicker` — popover showing all board labels as colored pills. Click to toggle on card. "Create label" at bottom opens inline input with color swatches.
-
-**Step 5: Build due date picker**
-
-`DueDatePicker` — simple date input. Shows relative time ("in 3 days", "overdue by 2 days"). Clear button to remove.
-
-**Step 6: Build attachment section**
-
-`AttachmentSection` — list of attachments with name and mode indicator. "Add" button triggers Tauri file dialog (link mode) or file dialog + copy (copy mode) based on board settings. Click attachment opens in default app via `tauri-plugin-shell`.
-
-**Step 7: Commit**
-
-```bash
-git add src/components/card-detail/
-git commit -m "feat: add two-panel card detail modal with markdown, checklist, labels, dates, attachments"
-```
-
----
-
-## Task 12: Command Palette
-
-**Files:**
-- Create: `src/components/command-palette/CommandPalette.tsx`
-- Modify: `src/App.tsx` (mount command palette)
-
-**Step 1: Build command palette**
-
-`CommandPalette` — uses shadcn `Command` (cmdk) in a Dialog. Listens for `Ctrl+K` globally. Groups:
-- **Cards** — fuzzy search across current board cards by title
-- **Boards** — switch to another board
-- **Actions** — "New Board", "New Card", "Toggle Dark Mode", "Settings"
-
-Search all boards using `searchAllBoards()` from storage when query is entered.
-
-**Step 2: Wire up actions**
-
-Each command item dispatches the appropriate store action or view change. Board switch: close current board, open new one. New card: adds to first column of current board.
-
-**Step 3: Mount in App.tsx**
-
-Add ` ` as a sibling to the main content in App.tsx. It renders as a Dialog overlay.
-
-**Step 4: Commit**
-
-```bash
-git add src/components/command-palette/ src/App.tsx
-git commit -m "feat: add command palette with cross-board search and actions"
-```
-
----
-
-## Task 13: Settings Dialog
-
-**Files:**
-- Create: `src/components/settings/SettingsDialog.tsx`
-
-**Step 1: Build settings dialog**
-
-`SettingsDialog` — shadcn Dialog triggered from top bar gear icon or command palette. Sections:
-- **Theme**: radio group (Light / Dark / System)
-- **Data directory**: shows current path, button to change via Tauri directory dialog
-- **Default attachment mode**: radio (Link / Copy)
-- **About**: app version, link to docs
-
-All changes save immediately to `settings.json` via the app store.
-
-**Step 2: Commit**
-
-```bash
-git add src/components/settings/
-git commit -m "feat: add settings dialog with theme, data directory, and attachment mode"
-```
-
----
-
-## Task 14: Keyboard Shortcuts
-
-**Files:**
-- Create: `src/hooks/useKeyboardShortcuts.ts`
-- Modify: `src/App.tsx` (mount hook)
-
-**Step 1: Create keyboard shortcuts hook**
-
-`useKeyboardShortcuts` — single `useEffect` with a `keydown` listener on `document`. Routes:
-- `Ctrl+K` → open command palette
-- `Ctrl+N` → new board dialog
-- `Ctrl+Z` → `useBoardStore.temporal.getState().undo()`
-- `Ctrl+Shift+Z` → `useBoardStore.temporal.getState().redo()`
-- `Ctrl+,` → open settings
-- `Escape` → close active modal
-- `N` (no modifier, board view, no input focused) → new card in focused column
-- Arrow keys → column/card focus navigation
-- `Enter` → open focused card detail
-- `D` → due date on focused card
-- `L` → label picker on focused card
-
-Use a focus tracking state (`focusedColumnIndex`, `focusedCardIndex`) stored in a ref to avoid re-renders.
-
-**Step 2: Commit**
-
-```bash
-git add src/hooks/useKeyboardShortcuts.ts src/App.tsx
-git commit -m "feat: add global keyboard shortcuts for navigation, undo/redo, and quick actions"
-```
-
----
-
-## Task 15: Import/Export
-
-**Files:**
-- Create: `src/lib/import-export.ts`
-- Create: `src/components/import-export/ExportDialog.tsx`
-- Create: `src/components/import-export/ImportDialog.tsx`
-
-**Step 1: Create import/export logic**
-
-`src/lib/import-export.ts`:
-- `exportBoardAsJson(board)` — Tauri save dialog, writes board JSON
-- `exportBoardAsCsv(board)` — flattens cards to CSV rows, Tauri save dialog
-- `importBoardFromJson(filePath)` — read file, validate with Zod, return Board
-- `importBoardFromCsv(filePath)` — parse CSV, map columns, create Board
-- `importFromTrello(filePath)` — parse Trello JSON export, map to OpenPylon schema
-
-**Step 2: Build export dialog**
-
-`ExportDialog` — choose format (JSON, CSV). Triggers Tauri save dialog with appropriate file extension filter.
-
-**Step 3: Build import dialog**
-
-`ImportDialog` — Tauri open dialog to select file. Detects format by extension. Shows preview (board name, card count). "Import" button creates the board in storage and refreshes board list.
-
-**Step 4: Add drag-and-drop file import**
-
-Listen for Tauri file drop events on the board list screen. If a `.json` or `.csv` is dropped, trigger the import flow.
-
-**Step 5: Commit**
-
-```bash
-git add src/lib/import-export.ts src/components/import-export/
-git commit -m "feat: add import/export for JSON, CSV, and Trello formats with drag-and-drop"
-```
-
----
-
-## Task 16: Animations with Framer Motion
-
-**Files:**
-- Create: `src/components/motion/AnimatedCard.tsx`
-- Create: `src/components/motion/AnimatedColumn.tsx`
-- Modify: `src/components/card-detail/CardDetailModal.tsx` (add layoutId morph)
-- Modify: `src/components/board/CardThumbnail.tsx` (add layoutId)
-
-**Step 1: Wrap card thumbnails with motion**
-
-Add Framer Motion `motion.div` with `layoutId={card.id}` to CardThumbnail. This enables the card-to-modal morph animation.
-
-**Step 2: Add card appear animation**
-
-New cards fade in + slide down with spring physics (200ms). Use `AnimatePresence` + `motion.div` with `initial={{ opacity: 0, y: -10 }}`, `animate={{ opacity: 1, y: 0 }}`.
-
-**Step 3: Add column appear animation**
-
-New columns slide in from the right (300ms). Same `AnimatePresence` pattern.
-
-**Step 4: Wire up card detail morph**
-
-In `CardDetailModal`, use `motion.div` with matching `layoutId` so the dialog content morphs from the card's position. Add `transition={{ type: "spring", stiffness: 300, damping: 30 }}`.
-
-**Step 5: Add checklist strikethrough animation**
-
-When a checklist item is checked, animate the strikethrough with a sweep effect (200ms CSS animation).
-
-**Step 6: Respect prefers-reduced-motion**
-
-All Framer Motion components check `useReducedMotion()` hook and skip animations when true.
-
-**Step 7: Commit**
-
-```bash
-git add src/components/motion/ src/components/card-detail/ src/components/board/
-git commit -m "feat: add Framer Motion animations with card morph, spring physics, and reduced-motion support"
-```
-
----
-
-## Task 17: Accessibility Pass
-
-**Files:**
-- Modify: various component files
-
-**Step 1: Audit and fix semantic HTML**
-
-Ensure all columns use `` with `aria-label`, card lists use `/`, buttons are `` not ``.
-
-**Step 2: Add ARIA attributes**
-
-- Column headers: `aria-label="To Do column, 4 cards"`
-- Cards: `role="article"`, `aria-label` with card title
-- Drag and drop: dnd-kit `announcements` prop for screen reader updates
-- Modals: `aria-modal="true"`, focus trap via shadcn Dialog (built-in)
-- `aria-live="polite"` region for save status announcements
-
-**Step 3: Focus indicators**
-
-Ensure all interactive elements have visible focus via:
-```css
-:focus-visible {
- outline: 2px solid oklch(var(--color-accent));
- outline-offset: 2px;
-}
-```
-
-**Step 4: High contrast support**
-
-```css
-@media (prefers-contrast: more) {
- /* Restore card borders, increase shadow intensity */
-}
-```
-
-**Step 5: Commit**
-
-```bash
-git add -A
-git commit -m "feat: accessibility pass — semantic HTML, ARIA, focus indicators, high contrast"
-```
-
----
-
-## Task 18: Window Close Handler + Final Polish
-
-**Files:**
-- Modify: `src/App.tsx`
-- Modify: `src-tauri/tauri.conf.json` (window config)
-
-**Step 1: Handle window close**
-
-In App.tsx, use Tauri's `onCloseRequested` to flush any pending board saves before the app exits:
-```typescript
-import { getCurrentWindow } from "@tauri-apps/api/window";
-
-useEffect(() => {
- const unlisten = getCurrentWindow().onCloseRequested(async (event) => {
- useBoardStore.getState().closeBoard();
- });
- return () => { unlisten.then((fn) => fn()); };
-}, []);
-```
-
-**Step 2: Configure Tauri window**
-
-In `src-tauri/tauri.conf.json`, set:
-```json
-{
- "app": {
- "windows": [
- {
- "title": "OpenPylon",
- "width": 1200,
- "height": 800,
- "minWidth": 800,
- "minHeight": 600
- }
- ]
- }
-}
-```
-
-**Step 3: Final build test**
-
-Run:
-```bash
-npm run tauri build
-```
-
-Expected: Builds a production executable.
-
-**Step 4: Commit**
-
-```bash
-git add -A
-git commit -m "feat: add window close handler, configure window size, production build ready"
-```
-
----
-
-## Task Dependency Map
-
-```
-Task 1 (scaffold) ─────────────────────────────────────────────┐
- │ │
-Task 2 (types + schemas) │
- │ │
-Task 3 (storage layer) │
- │ │
-Task 4 (Zustand stores) ──────────────────┐ │
- │ │ │
-Task 5 (theme + fonts) ──────────────────────────────────────────
- │ │
-Task 6 (app shell + router) │
- │ │
- ├── Task 7 (board list) │
- │ │ │
- │ ├── Task 15 (import/export) │
- │ │
- ├── Task 8 (columns) │
- │ │ │
- │ ├── Task 9 (card thumbnails) │
- │ │ │ │
- │ │ ├── Task 10 (drag & drop) ──┘
- │ │ │
- │ │ ├── Task 11 (card detail modal)
- │ │
- │ ├── Task 12 (command palette)
- │
- ├── Task 13 (settings dialog)
- │
- ├── Task 14 (keyboard shortcuts)
- │
- ├── Task 16 (animations) ── requires Tasks 9, 10, 11
- │
- ├── Task 17 (accessibility) ── requires all UI tasks
- │
- └── Task 18 (close handler + polish) ── final task
-```
-
----
-
-## Total Estimated Scope
-
-- **18 tasks**, ordered by dependency
-- Tasks 1-5: Foundation (scaffold, types, storage, stores, theme)
-- Tasks 6-9: Core UI (shell, board list, columns, cards)
-- Tasks 10-12: Interactivity (drag-and-drop, card detail, command palette)
-- Tasks 13-15: Features (settings, shortcuts, import/export)
-- Tasks 16-18: Polish (animations, accessibility, final build)
diff --git a/docs/plans/2026-02-15-openpylon-kanban-design.md b/docs/plans/2026-02-15-openpylon-kanban-design.md
deleted file mode 100644
index 06a3d65..0000000
--- a/docs/plans/2026-02-15-openpylon-kanban-design.md
+++ /dev/null
@@ -1,345 +0,0 @@
-# OpenPylon — Local-First Kanban Board Design Document
-
-**Date:** 2026-02-15
-**Status:** Approved
-
-## Overview
-
-OpenPylon is a local-first Kanban board desktop app for personal projects and task management. No account required, no cloud sync — a fast, drag-and-drop board that saves to local JSON files. Replaces Trello ($5-10/mo), Asana ($11/mo), Monday.com ($9/mo).
-
-## Tech Stack
-
-- **Runtime:** Tauri (Rust backend, system webview, ~5MB bundle)
-- **Frontend:** React + TypeScript
-- **State:** Zustand (monolithic store per board, debounced JSON persistence)
-- **Styling:** Tailwind CSS + shadcn/ui
-- **Drag & Drop:** dnd-kit
-- **Undo/Redo:** zundo (Zustand temporal middleware)
-
-## Architecture: Monolithic State Store
-
-Single Zustand store per board, loaded entirely into memory from JSON on open. All mutations go through the store and auto-save back to disk with debounced writes (500ms). Board data is small (even 500 cards is ~1MB of JSON), so full in-memory loading is fine.
-
----
-
-## Data Model
-
-### Directory Structure
-
-```
-~/.openpylon/
-├── settings.json # Global app settings
-├── boards/
-│ ├── board-
.json # One file per board
-│ └── board-.json
-└── attachments/
- └── board-/ # Copied attachments (when setting enabled)
- └── -filename.png
-```
-
-### Schema
-
-```typescript
-interface Board {
- id: string; // ULID
- title: string;
- color: string; // Accent color stripe for board list
- createdAt: string; // ISO 8601
- updatedAt: string;
- columns: Column[];
- cards: Record; // Flat map, referenced by columns
- labels: Label[]; // Board-level label definitions
- settings: BoardSettings; // Per-board settings (attachment mode, etc.)
-}
-
-interface Column {
- id: string;
- title: string;
- cardIds: string[]; // Ordered references
- width: "narrow" | "standard" | "wide"; // Collapsible widths
-}
-
-interface Card {
- id: string;
- title: string;
- description: string; // Markdown
- labels: string[]; // Label IDs
- checklist: ChecklistItem[];
- dueDate: string | null;
- attachments: Attachment[];
- createdAt: string;
- updatedAt: string;
-}
-
-interface Label {
- id: string;
- name: string;
- color: string;
-}
-
-interface ChecklistItem {
- id: string;
- text: string;
- checked: boolean;
-}
-
-interface Attachment {
- id: string;
- name: string;
- path: string; // Absolute (link mode) or relative (copy mode)
- mode: "link" | "copy";
-}
-```
-
-**Key decisions:**
-- ULIDs instead of UUIDs — sortable by creation time, no collisions
-- Cards stored flat (`cards: Record`) with columns referencing via `cardIds[]` — drag-and-drop reordering is a simple array splice
-- Labels defined at board level, referenced by ID on cards
-
----
-
-## State Management & Persistence
-
-### Stores
-
-- `useBoardStore` — active board's full state + all mutation actions
-- `useAppStore` — global app state: theme, recent boards, settings, current view
-
-### Persistence Flow
-
-1. Board open: Tauri `fs.readTextFile()` → parse JSON → validate with Zod → hydrate Zustand store
-2. On mutation: store subscribes to itself, debounces writes at 500ms
-3. On board close / app quit: immediate flush via Tauri `window.onCloseRequested`
-
-### Auto-Backup
-
-On every successful save, rotate previous version to `board-.backup.json` (one backup per board).
-
-### Undo/Redo
-
-zundo (Zustand temporal middleware) tracks state history. Ctrl+Z / Ctrl+Shift+Z. Capped at ~50 steps.
-
-### Search
-
-Global search reads all board JSON files from disk and searches card titles + descriptions. For personal Kanban (5-20 boards), this is instant. No index needed.
-
----
-
-## UI Design
-
-### Aesthetic Direction: Industrial Utility with Warmth
-
-"Pylon" evokes infrastructure and strength. The app should feel like a well-made tool — a carpenter's organized workshop, not an IKEA showroom.
-
-### Color Palette (OKLCH)
-
-**Light mode:**
-- Background: `oklch(97% 0.005 80)` — warm off-white
-- Surface/cards: `oklch(99% 0.003 80)` — barely-there warmth
-- Column background: `oklch(95% 0.008 80)` — subtle sand
-- Primary accent: `oklch(55% 0.12 160)` — muted teal-green
-- Text primary: `oklch(25% 0.015 50)` — warm near-black
-- Text secondary: `oklch(55% 0.01 50)` — warm gray
-- Danger/overdue: `oklch(55% 0.18 25)` — terracotta red
-
-**Dark mode:**
-- Background: `oklch(18% 0.01 50)` — warm dark
-- Surface: `oklch(22% 0.01 50)`
-- Cards: `oklch(25% 0.012 50)`
-
-### Typography
-
-- **Headings:** Instrument Serif — heritage serif with personality
-- **Body/cards:** Satoshi — clean geometric sans, readable at small sizes
-- **Metadata (labels, dates, counts):** Geist Mono — reinforces "tool" identity
-
-**Scale:**
-- Board title: `clamp(1.25rem, 2vw, 1.5rem)`, bold
-- Column headers: `0.8rem` uppercase, `letter-spacing: 0.08em`, weight 600
-- Card titles: `0.875rem`, weight 500
-- Card metadata: `0.75rem` monospace
-
-### App Shell Layout
-
-```
-┌──────────────────────────────────────────────────────┐
-│ ← Boards Sprint Planning ⌘K ⚙ │
-├──────────────────────────────────────────────────────┤
-│ │
-│ TO DO IN PROGRESS DONE │
-│ ───── 4 ─────────── 2 ──── 3 │
-│ │
-│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
-│ │ Card title │ │ Card title │ │ Card title │ │
-│ │ 🟢🔵 Feb28 ▮▮▯│ │ 🟢 ▮▮▮▮│ │ ▮▮▯ │ │
-│ └────────────┘ └────────────┘ └────────────┘ │
-│ │
-│ + Add card + Add card + Add card │
-│ │
-└──────────────────────────────────────────────────────┘
-```
-
-**Key layout decisions:**
-- No vertical column dividers — whitespace gaps (24-32px) instead
-- Column headers: uppercase, tracked-out, small — like section dividers
-- Card count as quiet number beside underline, not a badge
-- Command palette (`Ctrl+K`) replaces search icon
-- Theme toggle lives in settings, not top bar
-- Board title is click-to-edit inline, no `[edit]` button
-
-### Card Design
-
-- **Label dots:** 8px colored circles in a row, hover for tooltip with name
-- **Due date:** Monospace, right-aligned, no icon. Overdue turns terracotta with subtle tint.
-- **Checklist:** Tiny progress bar (filled/unfilled blocks), not "3/4" text
-- **No card borders.** Subtle shadow (`0 1px 3px oklch(0% 0 0 / 0.06)`) for separation.
-- **Hover:** `translateY(-1px)` lift with faint shadow deepening, spring physics, 150ms
-- **Drag ghost:** 5-degree rotation, `scale(1.03)`, `opacity(0.9)`, elevated shadow
-
-### Column Widths
-
-Columns support three widths: narrow (titles only), standard, wide (active focus). Double-click header to cycle. Adds spatial meaning.
-
-### Card Detail Modal (Two-Panel)
-
-```
-┌──────────────────────────────────────────────────────┐
-│ Fix auth token refresh ✕ │
-│ │
-│ ┌─────────────────────────┐ ┌────────────────────┐ │
-│ │ │ │ LABELS │ │
-│ │ Markdown description │ │ 🟢 Bug 🔵 Backend │ │
-│ │ with live preview │ │ │ │
-│ │ │ │ DUE DATE │ │
-│ │ │ │ Feb 28, 2026 │ │
-│ │ │ │ │ │
-│ │ │ │ CHECKLIST 3/4 │ │
-│ │ │ │ ✓ Research APIs │ │
-│ │ │ │ ✓ Write tests │ │
-│ │ │ │ ✓ Implement │ │
-│ │ │ │ ○ Code review │ │
-│ │ │ │ │ │
-│ │ │ │ ATTACHMENTS │ │
-│ │ │ │ spec.pdf │ │
-│ └─────────────────────────┘ └────────────────────┘ │
-└──────────────────────────────────────────────────────┘
-```
-
-- Left panel (60%): Title (inline edit) + markdown description (edit/preview toggle)
-- Right sidebar (40%): Labels, due date, checklist, attachments. Each collapsible.
-- No save button — auto-persist with subtle "Saved" indicator
-- **Card-to-modal morph animation** via Framer Motion `layoutId` — modal grows from card position
-
-### Command Palette (`Ctrl+K`)
-
-Using shadcn's `cmdk` component:
-- Search all cards across all boards by title/description
-- Switch between boards
-- Create new cards/boards
-- Toggle theme
-- Open settings
-- Navigate to specific column
-- Filter current board by label/date
-
-### Board List (Home Screen)
-
-Grid of board cards with:
-- Color accent stripe at top (user-chosen per board)
-- Title, card count, column count
-- Relative time ("2 min ago", "Yesterday")
-- Right-click context menu: Duplicate, Export, Delete, Change color
-- Empty state: "Create your first board" + single button
-
----
-
-## Keyboard Shortcuts
-
-### Global
-
-| Action | Shortcut |
-|---|---|
-| Command palette | `Ctrl+K` |
-| New card in focused column | `N` |
-| New board | `Ctrl+N` |
-| Undo | `Ctrl+Z` |
-| Redo | `Ctrl+Shift+Z` |
-| Settings | `Ctrl+,` |
-| Close modal / cancel | `Escape` |
-| Save & close card detail | `Ctrl+Enter` |
-
-### Board Navigation
-
-- `Arrow Left/Right` — focus prev/next column
-- `Arrow Up/Down` — focus prev/next card in column
-- `Enter` — open focused card detail
-- `Space` — quick-toggle first unchecked checklist item
-- `D` — set/edit due date on focused card
-- `L` — open label picker on focused card
-
-### Drag-and-Drop Keyboard
-
-dnd-kit keyboard sensor: `Space` to pick up, arrows to move, `Space` to drop, `Escape` to cancel. Movements announced via `aria-live` region.
-
----
-
-## Accessibility
-
-- All interactive elements reachable via Tab
-- Focus indicators: `2px solid` accent color, `2px offset`, visible in both themes
-- Modal focus trapping
-- Column/card counts via `aria-label`
-- `prefers-reduced-motion`: all animations collapse to instant
-- `prefers-contrast`: increased shadow intensity, subtle borders restored
-- Minimum touch target: 44x44px on all buttons
-
----
-
-## Import/Export
-
-### Export
-
-- **JSON:** The board file itself is the export. Save As dialog.
-- **CSV:** Flattened — one row per card with all fields.
-- **ZIP:** For boards with copy-mode attachments — board JSON + attachments folder.
-
-### Import
-
-- **OpenPylon JSON:** Drop file onto board list or use File > Import. Schema validation + preview before importing.
-- **CSV:** Import wizard — map columns, preview rows, choose target board.
-- **Trello JSON:** Dedicated adapter mapping Trello schema to OpenPylon.
-- **Drag-and-drop import:** Dropping `.json` or `.csv` anywhere triggers import flow.
-
----
-
-## Error Handling
-
-- **Corrupted board file:** Recovery dialog — inspect in file explorer or restore from `.backup.json`
-- **Data directory inaccessible:** Dialog to choose new directory on startup
-- **Disk full:** Inline toast, changes preserved in memory, retry every 30s
-- **File locked:** Warning dialog
-- **Schema migration:** On load, validate with Zod, add missing fields with defaults, preserve unknown fields
-- **Drag edge cases:** Empty column droppable, drop outside cancels with spring return
-
-## Micro-Interactions
-
-| Interaction | Animation | Duration |
-|---|---|---|
-| Card appears (new) | Fade in + slide down | 200ms, spring |
-| Card drag start | Lift + rotate + shadow | 150ms |
-| Card drop | Settle with slight bounce | 250ms, spring |
-| Column add | Slide in from right | 300ms |
-| Card detail open | Morph from card position | 250ms |
-| Card detail close | Reverse morph to card | 200ms |
-| Checklist check | Strikethrough sweep + fill | 200ms |
-| Board switch | Crossfade | 300ms |
-
-All animations respect `prefers-reduced-motion`.
-
----
-
-## Empty States
-
-- **No boards:** "Create your first board" + button + minimal illustration
-- **Empty column:** Dashed border area + "Drag cards here or click + to add"
-- **No search results:** "No matches" + suggestion to broaden
-- **No labels:** "Create your first label" + color swatches
diff --git a/docs/plans/2026-02-15-visual-glow-up-design.md b/docs/plans/2026-02-15-visual-glow-up-design.md
deleted file mode 100644
index f552af0..0000000
--- a/docs/plans/2026-02-15-visual-glow-up-design.md
+++ /dev/null
@@ -1,235 +0,0 @@
-# OpenPylon Visual Glow-Up Design
-
-**Date:** 2026-02-15
-**Goal:** Transform OpenPylon from functional-but-bare to visually polished and delightful
-**Approach:** Settings-first foundation — build the settings infrastructure, then layer visual features on top
-
----
-
-## 1. Settings Model & Infrastructure
-
-Expand `AppSettings` in `src/types/settings.ts`:
-
-```typescript
-export interface AppSettings {
- theme: "light" | "dark" | "system";
- dataDirectory: string | null;
- recentBoardIds: string[];
-
- // Appearance
- accentColor: string; // OKLCH hue (0-360), default "160" (teal)
- uiZoom: number; // 0.75-1.5, default 1.0
- density: "compact" | "comfortable" | "spacious";
-
- // Board defaults
- defaultColumnWidth: ColumnWidth; // default "standard"
-}
-```
-
-### Zoom
-Set `font-size` on `` to `uiZoom * 16px`. Everything uses `rem` via Tailwind, so the entire UI scales proportionally.
-
-### Accent Color
-Store an OKLCH hue value. On apply, regenerate `--pylon-accent` as `oklch(55% 0.12 {hue})` (light) / `oklch(60% 0.12 {hue})` (dark).
-
-### Density
-Set CSS custom property `--density-factor` (compact=0.75, comfortable=1.0, spacious=1.25). Use it to scale padding on columns, cards, and gaps.
-
-### App Store Changes
-Add `setAccentColor`, `setUiZoom`, `setDensity`, `setDefaultColumnWidth` actions to `app-store.ts`. Each saves immediately (no Save button). Add `applyAppearance()` function that applies zoom, accent, and density to the DOM — called on init and on any change.
-
----
-
-## 2. Settings Panel UI
-
-Transform `SettingsDialog.tsx` from a tiny modal into a tabbed panel.
-
-- Widen to `sm:max-w-lg`
-- 4 tabs: **Appearance** | **Boards** | **Keyboard Shortcuts** | **About**
-- Simple button-based tab navigation (no library needed)
-
-### Appearance Tab
-- **Theme** — existing 3-button toggle (unchanged)
-- **UI Zoom** — slider 75%-150% in 5% steps, live preview, reset button, shows current %
-- **Accent Color** — 10 preset OKLCH hue swatches: teal/160, blue/240, purple/300, pink/350, red/25, orange/55, yellow/85, lime/130, cyan/200, slate/achromatic. Click to apply immediately.
-- **Density** — 3-button toggle: Compact / Comfortable / Spacious
-
-### Boards Tab
-- **Default column width** — 3-button toggle: Narrow / Standard / Wide
-
-### Keyboard Shortcuts Tab
-- Read-only reference table, two-column: key combo (mono font) | description
-- All shortcuts: Ctrl+K, Ctrl+Z, Ctrl+Y, Ctrl+N, ?, etc.
-
-### About Tab
-- App name, version, tagline
-- Link to repo (opens via Tauri shell)
-
----
-
-## 3. Board Color Applied to UI
-
-Currently `board.color` only shows on BoardCard in the home screen.
-
-- **TopBar:** 2px bottom border in board color when viewing a board. Color dot next to board title.
-- **Column headers:** 3px top-border in board color at 30% opacity.
-- **No full background tinting** — structural accents only (borders, dots).
-
----
-
-## 4. Column Colors
-
-Extend `Column` interface:
-
-```typescript
-export interface Column {
- id: string;
- title: string;
- cardIds: string[];
- width: ColumnWidth;
- color: string | null; // optional OKLCH hue, null = use board color
-}
-```
-
-- Set via "Color" submenu in ColumnHeader dropdown (same 10 swatches + "None")
-- Column's 3px top-border uses column color when set, falls back to board color
-- Column background stays neutral
-
----
-
-## 5. Card Cover Colors
-
-Extend `Card` interface:
-
-```typescript
-export interface Card {
- // ...existing
- coverColor: string | null; // OKLCH hue for color strip
-}
-```
-
-- No image uploads for v1 — just a color bar
-- 4px colored bar at top of CardThumbnail
-- Set via swatch picker in CardDetailModal
-- Simple CSS, no layout disruption
-
----
-
-## 6. Richer Card Thumbnails
-
-Add to existing CardThumbnail footer row:
-
-- **Attachment indicator** — paperclip icon + count (if `attachments.length > 0`)
-- **Description indicator** — text-lines icon (if `description` is non-empty)
-- **Cover color bar** — from Section 5
-
-No priority badges or assignees — keeping thumbnails clean.
-
----
-
-## 7. Toast Notification System
-
-- `useToastStore` — Zustand store: `{ id, message, type }[]`
-- `` in App.tsx — fixed bottom-right, pills with auto-dismiss (3s + fade)
-- Types: `success` (green), `error` (red), `info` (neutral)
-- Fires on: board deleted, board exported, board imported, import failed, save error
-
----
-
-## 8. Undo/Redo Buttons in TopBar
-
-- Two icon buttons: RotateCcw (undo) and RotateCw (redo)
-- Placed in TopBar right section, before command palette button
-- Disabled when at start/end of history
-- Only visible in board view
-- Tooltips show keyboard shortcuts (Ctrl+Z / Ctrl+Y)
-
----
-
-## 9. Keyboard Shortcut Help Modal
-
-- Triggered by `?` key (when not in an input/textarea)
-- Two-column grid grouped by category: Navigation, Board, Cards
-- Same data as Settings keyboard shortcuts tab
-- Lightweight modal, dismissible with Escape or clicking outside
-
----
-
-## 10. Board Backgrounds
-
-Extend `BoardSettings`:
-
-```typescript
-export interface BoardSettings {
- attachmentMode: "link" | "copy";
- background: "none" | "dots" | "grid" | "gradient";
-}
-```
-
-- **none** — plain (current)
-- **dots** — subtle radial-gradient dot pattern, 5% opacity
-- **grid** — subtle grid lines via CSS
-- **gradient** — soft gradient using board color at 3-5% opacity
-- Set via board settings dropdown (gear icon in TopBar when viewing a board)
-
----
-
-## 11. Onboarding / Empty States
-
-- **First launch (zero boards):** Upgraded empty state — welcoming message, prominent "Create Board" button, secondary "Import Board" option
-- **Empty column:** Dashed-border area with "Drop or add a card" text
-- **Empty description:** "Click to add a description..." placeholder
-- **Empty checklist:** "Add your first item..." when empty
-
----
-
-## 12. Polish Pass
-
-- Consistent hover transitions (200ms ease) across all interactive elements
-- Verify focus rings work with all accent colors
-- Test Framer Motion springs at different zoom levels
-- Dark mode testing for all new features (column colors, card covers, backgrounds)
-- Thin, themed scrollbars on column scroll areas
-
----
-
-## Implementation Order
-
-1. Settings model + app store actions + CSS variable application
-2. Settings panel UI (tabbed, all sections)
-3. UI zoom
-4. Accent color
-5. Density toggle
-6. Board color in UI (TopBar + column headers)
-7. Column colors
-8. Card cover colors
-9. Richer card thumbnails
-10. Toast notification system
-11. Undo/redo buttons
-12. Keyboard shortcut help modal
-13. Board backgrounds
-14. Onboarding / empty states
-15. Polish pass
-
-## Files Affected
-
-- `src/types/settings.ts` — expanded AppSettings
-- `src/types/board.ts` — Column.color, Card.coverColor, BoardSettings.background
-- `src/stores/app-store.ts` — new actions, applyAppearance()
-- `src/components/settings/SettingsDialog.tsx` — full rewrite (tabbed)
-- `src/index.css` — density variables, zoom hook, background patterns
-- `src/components/layout/TopBar.tsx` — board color, undo/redo buttons, board settings gear
-- `src/components/board/KanbanColumn.tsx` — column color border
-- `src/components/board/ColumnHeader.tsx` — color submenu
-- `src/components/board/CardThumbnail.tsx` — cover bar, attachment/description indicators
-- `src/components/card-detail/CardDetailModal.tsx` — cover color picker
-- `src/components/board/BoardView.tsx` — background patterns
-- `src/App.tsx` — ToastContainer, shortcut help modal, appearance init
-- `src/stores/toast-store.ts` — NEW
-- `src/components/toast/ToastContainer.tsx` — NEW
-- `src/components/shortcuts/ShortcutHelpModal.tsx` — NEW
-- `src/stores/board-store.ts` — new actions for column color, card cover
-- `src/lib/board-factory.ts` — defaults for new fields
-- `src/lib/schemas.ts` — migration for new fields
-- `src/components/boards/BoardList.tsx` — upgraded empty state
-- `src/hooks/useKeyboardShortcuts.ts` — ? key handler
diff --git a/docs/plans/2026-02-15-visual-glow-up-implementation.md b/docs/plans/2026-02-15-visual-glow-up-implementation.md
deleted file mode 100644
index f7c662d..0000000
--- a/docs/plans/2026-02-15-visual-glow-up-implementation.md
+++ /dev/null
@@ -1,1766 +0,0 @@
-# Visual Glow-Up Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Transform OpenPylon from functional-but-bare to visually polished — settings infrastructure, UI zoom, accent colors, density, board/column/card colors, toasts, keyboard help, backgrounds, onboarding, and polish.
-
-**Architecture:** Settings-first approach. Expand the AppSettings type with new appearance fields, wire them through the Zustand app-store to CSS custom properties on ``, then build the tabbed settings UI. Visual features (board colors, column colors, card covers, etc.) layer on top. Toast system is an independent Zustand store. No new npm dependencies needed — everything uses existing Tailwind, Lucide, Framer Motion, and Radix primitives.
-
-**Tech Stack:** React 19, TypeScript, Zustand 5, Tailwind CSS 4, Radix UI, Lucide React, Framer Motion, Tauri v2, Zod 4
-
----
-
-### Task 1: Expand Settings Type & Schema
-
-**Files:**
-- Modify: `src/types/settings.ts`
-- Modify: `src/lib/schemas.ts`
-
-**Step 1: Update AppSettings interface**
-
-In `src/types/settings.ts`, replace the entire file:
-
-```typescript
-import type { ColumnWidth } from "./board";
-
-export interface AppSettings {
- theme: "light" | "dark" | "system";
- dataDirectory: string | null;
- recentBoardIds: string[];
-
- // Appearance
- accentColor: string;
- uiZoom: number;
- density: "compact" | "comfortable" | "spacious";
-
- // Board defaults
- defaultColumnWidth: ColumnWidth;
-}
-```
-
-**Step 2: Update the Zod schema**
-
-In `src/lib/schemas.ts`, replace the `appSettingsSchema`:
-
-```typescript
-export const appSettingsSchema = z.object({
- theme: z.enum(["light", "dark", "system"]).default("system"),
- dataDirectory: z.string().nullable().default(null),
- recentBoardIds: z.array(z.string()).default([]),
- accentColor: z.string().default("160"),
- uiZoom: z.number().min(0.75).max(1.5).default(1),
- density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"),
- defaultColumnWidth: z.enum(["narrow", "standard", "wide"]).default("standard"),
-});
-```
-
-**Step 3: Verify types compile**
-
-Run: `npx tsc --noEmit`
-Expected: Should fail — `app-store.ts` default value doesn't include the new fields yet. That's expected, we'll fix it in Task 2.
-
-**Step 4: Commit**
-
-```bash
-git add src/types/settings.ts src/lib/schemas.ts
-git commit -m "feat: expand AppSettings with appearance and board default fields"
-```
-
----
-
-### Task 2: Wire App Store with Appearance Application
-
-**Files:**
-- Modify: `src/stores/app-store.ts`
-
-**Step 1: Add applyAppearance and new actions**
-
-Replace `src/stores/app-store.ts` entirely:
-
-```typescript
-import { create } from "zustand";
-import type { AppSettings } from "@/types/settings";
-import type { BoardMeta, ColumnWidth } from "@/types/board";
-import { loadSettings, saveSettings, listBoards, ensureDataDirs } from "@/lib/storage";
-
-export type View = { type: "board-list" } | { type: "board"; boardId: string };
-
-interface AppState {
- settings: AppSettings;
- boards: BoardMeta[];
- view: View;
- initialized: boolean;
-
- init: () => Promise;
- setTheme: (theme: AppSettings["theme"]) => void;
- setAccentColor: (hue: string) => void;
- setUiZoom: (zoom: number) => void;
- setDensity: (density: AppSettings["density"]) => void;
- setDefaultColumnWidth: (width: ColumnWidth) => void;
- setView: (view: View) => void;
- refreshBoards: () => Promise;
- addRecentBoard: (boardId: string) => void;
-}
-
-function applyTheme(theme: AppSettings["theme"]): void {
- const root = document.documentElement;
- if (theme === "system") {
- const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
- root.classList.toggle("dark", prefersDark);
- } else {
- root.classList.toggle("dark", theme === "dark");
- }
-}
-
-function applyAppearance(settings: AppSettings): void {
- const root = document.documentElement;
-
- // Zoom: set root font-size (Tailwind rem units scale automatically)
- root.style.fontSize = `${settings.uiZoom * 16}px`;
-
- // Accent color: regenerate --pylon-accent from OKLCH hue
- const hue = settings.accentColor;
- const isDark = root.classList.contains("dark");
- const lightness = isDark ? "60%" : "55%";
- root.style.setProperty("--pylon-accent", `oklch(${lightness} 0.12 ${hue})`);
-
- // Density factor
- const densityMap = { compact: "0.75", comfortable: "1", spacious: "1.25" };
- root.style.setProperty("--density-factor", densityMap[settings.density]);
-}
-
-function updateAndSave(
- get: () => AppState,
- set: (partial: Partial) => void,
- patch: Partial
-): void {
- const settings = { ...get().settings, ...patch };
- set({ settings });
- saveSettings(settings);
-}
-
-export const useAppStore = create((set, get) => ({
- settings: {
- theme: "system",
- dataDirectory: null,
- recentBoardIds: [],
- accentColor: "160",
- uiZoom: 1,
- density: "comfortable",
- defaultColumnWidth: "standard",
- },
- boards: [],
- view: { type: "board-list" },
- initialized: false,
-
- init: async () => {
- await ensureDataDirs();
- const settings = await loadSettings();
- const boards = await listBoards();
- set({ settings, boards, initialized: true });
- applyTheme(settings.theme);
- applyAppearance(settings);
- },
-
- setTheme: (theme) => {
- updateAndSave(get, set, { theme });
- applyTheme(theme);
- // Re-apply appearance since accent lightness depends on dark/light
- applyAppearance({ ...get().settings, theme });
- },
-
- setAccentColor: (accentColor) => {
- updateAndSave(get, set, { accentColor });
- applyAppearance(get().settings);
- },
-
- setUiZoom: (uiZoom) => {
- updateAndSave(get, set, { uiZoom });
- applyAppearance(get().settings);
- },
-
- setDensity: (density) => {
- updateAndSave(get, set, { density });
- applyAppearance(get().settings);
- },
-
- setDefaultColumnWidth: (defaultColumnWidth) => {
- updateAndSave(get, set, { defaultColumnWidth });
- },
-
- setView: (view) => set({ view }),
-
- refreshBoards: async () => {
- const boards = await listBoards();
- set({ boards });
- },
-
- addRecentBoard: (boardId) => {
- const settings = get().settings;
- const recent = [
- boardId,
- ...settings.recentBoardIds.filter((id) => id !== boardId),
- ].slice(0, 10);
- updateAndSave(get, set, { recentBoardIds: recent });
- },
-}));
-```
-
-**Step 2: Verify types compile**
-
-Run: `npx tsc --noEmit`
-Expected: PASS (or only unrelated warnings)
-
-**Step 3: Commit**
-
-```bash
-git add src/stores/app-store.ts
-git commit -m "feat: wire app store with appearance actions and CSS variable application"
-```
-
----
-
-### Task 3: Add Density CSS Variable Support
-
-**Files:**
-- Modify: `src/index.css`
-
-**Step 1: Add density variable defaults to `:root`**
-
-After the existing `--radius: 0.625rem;` line in `:root`, add:
-
-```css
---density-factor: 1;
-```
-
-**Step 2: Verify the app still loads**
-
-Run: `npm run dev` — open http://localhost:1420, confirm no visual breakage.
-
-**Step 3: Commit**
-
-```bash
-git add src/index.css
-git commit -m "feat: add density CSS variable with default value"
-```
-
----
-
-### Task 4: Rewrite Settings Dialog as Tabbed Panel
-
-**Files:**
-- Modify: `src/components/settings/SettingsDialog.tsx`
-
-**Step 1: Rewrite SettingsDialog with four tabs**
-
-Replace the entire file with a tabbed settings panel. Tabs: Appearance, Boards, Keyboard Shortcuts, About.
-
-```typescript
-import { useState } from "react";
-import {
- Sun, Moon, Monitor, RotateCcw,
- Columns3, LayoutList, Maximize,
-} from "lucide-react";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@/components/ui/dialog";
-import { Separator } from "@/components/ui/separator";
-import { Button } from "@/components/ui/button";
-import { useAppStore } from "@/stores/app-store";
-import type { AppSettings } from "@/types/settings";
-import type { ColumnWidth } from "@/types/board";
-
-interface SettingsDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-type Tab = "appearance" | "boards" | "shortcuts" | "about";
-
-const THEME_OPTIONS: {
- value: AppSettings["theme"];
- label: string;
- icon: typeof Sun;
-}[] = [
- { value: "light", label: "Light", icon: Sun },
- { value: "dark", label: "Dark", icon: Moon },
- { value: "system", label: "System", icon: Monitor },
-];
-
-const ACCENT_PRESETS: { hue: string; label: string }[] = [
- { hue: "160", label: "Teal" },
- { hue: "240", label: "Blue" },
- { hue: "300", label: "Purple" },
- { hue: "350", label: "Pink" },
- { hue: "25", label: "Red" },
- { hue: "55", label: "Orange" },
- { hue: "85", label: "Yellow" },
- { hue: "130", label: "Lime" },
- { hue: "200", label: "Cyan" },
- { hue: "0", label: "Slate" },
-];
-
-const DENSITY_OPTIONS: {
- value: AppSettings["density"];
- label: string;
-}[] = [
- { value: "compact", label: "Compact" },
- { value: "comfortable", label: "Comfortable" },
- { value: "spacious", label: "Spacious" },
-];
-
-const WIDTH_OPTIONS: { value: ColumnWidth; label: string }[] = [
- { value: "narrow", label: "Narrow" },
- { value: "standard", label: "Standard" },
- { value: "wide", label: "Wide" },
-];
-
-const SHORTCUTS: { key: string; description: string; category: string }[] = [
- { key: "Ctrl+K", description: "Open command palette", category: "Navigation" },
- { key: "Ctrl+Z", description: "Undo", category: "Board" },
- { key: "Ctrl+Shift+Z", description: "Redo", category: "Board" },
- { key: "?", description: "Keyboard shortcuts", category: "Navigation" },
- { key: "Escape", description: "Close modal / cancel", category: "Navigation" },
-];
-
-const TABS: { value: Tab; label: string }[] = [
- { value: "appearance", label: "Appearance" },
- { value: "boards", label: "Boards" },
- { value: "shortcuts", label: "Shortcuts" },
- { value: "about", label: "About" },
-];
-
-function SectionLabel({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
- const [tab, setTab] = useState("appearance");
- const settings = useAppStore((s) => s.settings);
- const setTheme = useAppStore((s) => s.setTheme);
- const setAccentColor = useAppStore((s) => s.setAccentColor);
- const setUiZoom = useAppStore((s) => s.setUiZoom);
- const setDensity = useAppStore((s) => s.setDensity);
- const setDefaultColumnWidth = useAppStore((s) => s.setDefaultColumnWidth);
-
- return (
-
-
-
-
- Settings
-
-
- Configure your OpenPylon preferences.
-
-
-
- {/* Tab bar */}
-
- {TABS.map((t) => (
- setTab(t.value)}
- className="font-mono text-xs"
- >
- {t.label}
-
- ))}
-
-
- {/* Tab content */}
-
- {tab === "appearance" && (
- <>
- {/* Theme */}
-
-
Theme
-
- {THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
- setTheme(value)}
- className="flex-1 gap-2"
- >
-
- {label}
-
- ))}
-
-
-
-
-
- {/* UI Zoom */}
-
-
-
UI Zoom
-
-
- {Math.round(settings.uiZoom * 100)}%
-
- {settings.uiZoom !== 1 && (
- setUiZoom(1)}
- className="text-pylon-text-secondary hover:text-pylon-text"
- >
-
-
- )}
-
-
-
setUiZoom(parseFloat(e.target.value))}
- className="w-full accent-pylon-accent"
- />
-
- 75%
- 100%
- 150%
-
-
-
-
-
- {/* Accent Color */}
-
-
Accent Color
-
- {ACCENT_PRESETS.map(({ hue, label }) => {
- const isAchromatic = hue === "0";
- const bg = isAchromatic
- ? "oklch(50% 0 0)"
- : `oklch(55% 0.12 ${hue})`;
- return (
- setAccentColor(hue)}
- className="size-7 rounded-full transition-transform hover:scale-110"
- style={{
- backgroundColor: bg,
- outline: settings.accentColor === hue ? "2px solid currentColor" : "none",
- outlineOffset: "2px",
- }}
- aria-label={label}
- title={label}
- />
- );
- })}
-
-
-
-
-
- {/* Density */}
-
-
Density
-
- {DENSITY_OPTIONS.map(({ value, label }) => (
- setDensity(value)}
- className="flex-1"
- >
- {label}
-
- ))}
-
-
- >
- )}
-
- {tab === "boards" && (
-
-
Default Column Width
-
- {WIDTH_OPTIONS.map(({ value, label }) => (
- setDefaultColumnWidth(value)}
- className="flex-1"
- >
- {label}
-
- ))}
-
-
- )}
-
- {tab === "shortcuts" && (
-
- {SHORTCUTS.map(({ key, description }) => (
-
- {description}
-
- {key}
-
-
- ))}
-
- )}
-
- {tab === "about" && (
-
-
OpenPylon
-
- v0.1.0 · Local-first Kanban board
-
-
- Built with Tauri, React, and TypeScript.
-
-
- )}
-
-
-
- );
-}
-```
-
-**Step 2: Verify the app loads and settings dialog works**
-
-Run: `npm run dev`, open Settings, verify all four tabs render and settings persist.
-
-**Step 3: Commit**
-
-```bash
-git add src/components/settings/SettingsDialog.tsx
-git commit -m "feat: rewrite settings dialog with tabbed panel — appearance, boards, shortcuts, about"
-```
-
----
-
-### Task 5: Apply Density to Cards and Columns
-
-**Files:**
-- Modify: `src/components/board/KanbanColumn.tsx`
-- Modify: `src/components/board/CardThumbnail.tsx`
-- Modify: `src/components/board/BoardView.tsx`
-
-**Step 1: Update KanbanColumn to use density-responsive padding**
-
-In `KanbanColumn.tsx`, change the card list `` padding from `p-2` to use `calc()`:
-
-Replace:
-```
-className="flex min-h-[40px] list-none flex-col gap-2 p-2"
-```
-With:
-```
-className="flex min-h-[40px] list-none flex-col p-2" style={{ gap: `calc(0.5rem * var(--density-factor))`, padding: `calc(0.5rem * var(--density-factor))` }}
-```
-
-**Step 2: Update CardThumbnail to use density-responsive padding**
-
-In `CardThumbnail.tsx`, change the card button padding from `p-3` to inline style:
-
-Replace the `className` on the ``:
-```
-className="w-full rounded-lg bg-pylon-surface shadow-sm text-left transition-all duration-200 hover:-translate-y-px hover:shadow-md"
-```
-And add a style prop:
-```
-style={{ ...style, padding: `calc(0.75rem * var(--density-factor))` }}
-```
-
-(Merge the existing DnD `style` with the padding.)
-
-**Step 3: Update BoardView gap**
-
-In `BoardView.tsx`, change the board container `gap-6 p-6` to use density:
-
-Replace:
-```
-className="flex h-full gap-6 overflow-x-auto p-6"
-```
-With:
-```
-className="flex h-full overflow-x-auto" style={{ gap: `calc(1.5rem * var(--density-factor))`, padding: `calc(1.5rem * var(--density-factor))` }}
-```
-
-**Step 4: Verify all three density modes look correct**
-
-Open Settings > Appearance > toggle between Compact/Comfortable/Spacious. Verify cards and columns compress and expand.
-
-**Step 5: Commit**
-
-```bash
-git add src/components/board/KanbanColumn.tsx src/components/board/CardThumbnail.tsx src/components/board/BoardView.tsx
-git commit -m "feat: apply density factor to card, column, and board spacing"
-```
-
----
-
-### Task 6: Board Color in UI (TopBar + Column Headers)
-
-**Files:**
-- Modify: `src/components/layout/TopBar.tsx`
-- Modify: `src/components/board/ColumnHeader.tsx`
-
-**Step 1: Add board color accent to TopBar**
-
-In `TopBar.tsx`, modify the `` to show the board color as a bottom border when viewing a board:
-
-Replace the header className:
-```
-className="flex h-12 shrink-0 items-center gap-2 border-b border-border bg-pylon-surface px-3"
-```
-With:
-```
-className="flex h-12 shrink-0 items-center gap-2 bg-pylon-surface px-3"
-style={{ borderBottom: isBoardView && board ? `2px solid ${board.color}` : '1px solid var(--border)' }}
-```
-
-Also add a color dot next to the board title. In the center section, before the board title text/button, add:
-
-```tsx
-
-```
-
-**Step 2: Add board color to column headers**
-
-In `ColumnHeader.tsx`, add a `boardColor` prop:
-
-Update the interface:
-```typescript
-interface ColumnHeaderProps {
- column: Column;
- cardCount: number;
- boardColor?: string;
-}
-```
-
-Add a 3px top border to the column header wrapper:
-
-Replace the outer div className:
-```
-className="flex items-center justify-between border-b border-border px-3 pb-2 pt-3"
-```
-With:
-```
-className="flex items-center justify-between border-b border-border px-3 pb-2 pt-3"
-style={{ borderTop: boardColor ? `3px solid ${boardColor}30` : undefined }}
-```
-
-(The `30` suffix is hex 30% opacity appended to the hex color.)
-
-**Step 3: Pass boardColor through KanbanColumn**
-
-In `KanbanColumn.tsx`, pass the board color to ColumnHeader:
-
-```tsx
-
-```
-
-**Step 4: Verify board color shows in TopBar border and column headers**
-
-Open a board — verify the bottom border of the TopBar matches the board color and columns have a faint colored top line.
-
-**Step 5: Commit**
-
-```bash
-git add src/components/layout/TopBar.tsx src/components/board/ColumnHeader.tsx src/components/board/KanbanColumn.tsx
-git commit -m "feat: apply board color to TopBar border and column header accents"
-```
-
----
-
-### Task 7: Expand Board Types (Column Color, Card Cover, Board Background)
-
-**Files:**
-- Modify: `src/types/board.ts`
-- Modify: `src/lib/schemas.ts`
-- Modify: `src/lib/board-factory.ts`
-- Modify: `src/stores/board-store.ts`
-
-**Step 1: Update Column, Card, and BoardSettings types**
-
-In `src/types/board.ts`:
-
-Add `color` to Column:
-```typescript
-export interface Column {
- id: string;
- title: string;
- cardIds: string[];
- width: ColumnWidth;
- color: string | null;
-}
-```
-
-Add `coverColor` to Card:
-```typescript
-export interface Card {
- id: string;
- title: string;
- description: string;
- labels: string[];
- checklist: ChecklistItem[];
- dueDate: string | null;
- attachments: Attachment[];
- coverColor: string | null;
- createdAt: string;
- updatedAt: string;
-}
-```
-
-Add `background` to BoardSettings:
-```typescript
-export interface BoardSettings {
- attachmentMode: "link" | "copy";
- background: "none" | "dots" | "grid" | "gradient";
-}
-```
-
-**Step 2: Update Zod schemas**
-
-In `src/lib/schemas.ts`:
-
-Update `columnSchema`:
-```typescript
-export const columnSchema = z.object({
- id: z.string(),
- title: z.string(),
- cardIds: z.array(z.string()).default([]),
- width: z.enum(["narrow", "standard", "wide"]).default("standard"),
- color: z.string().nullable().default(null),
-});
-```
-
-Update `cardSchema` — add `coverColor` after `attachments`:
-```typescript
-coverColor: z.string().nullable().default(null),
-```
-
-Update `boardSettingsSchema`:
-```typescript
-export const boardSettingsSchema = z.object({
- attachmentMode: z.enum(["link", "copy"]).default("link"),
- background: z.enum(["none", "dots", "grid", "gradient"]).default("none"),
-});
-```
-
-**Step 3: Update board-factory defaults**
-
-In `src/lib/board-factory.ts`, update the `col` helper to include `color: null`:
-
-```typescript
-const col = (t: string, w: ColumnWidth = "standard") => ({
- id: ulid(),
- title: t,
- cardIds: [] as string[],
- width: w,
- color: null as string | null,
-});
-```
-
-And update the board's default settings:
-```typescript
-settings: { attachmentMode: "link", background: "none" as const },
-```
-
-**Step 4: Update board-store addCard and addColumn**
-
-In `src/stores/board-store.ts`:
-
-In `addColumn`, add `color: null`:
-```typescript
-{
- id: ulid(),
- title,
- cardIds: [],
- width: "standard" as ColumnWidth,
- color: null,
-}
-```
-
-In `addCard`, add `coverColor: null` to the card object:
-```typescript
-const card: Card = {
- id: cardId,
- title,
- description: "",
- labels: [],
- checklist: [],
- dueDate: null,
- attachments: [],
- coverColor: null,
- createdAt: now(),
- updatedAt: now(),
-};
-```
-
-Add a `setColumnColor` action to the store interface and implementation:
-
-Interface addition:
-```typescript
-setColumnColor: (columnId: string, color: string | null) => void;
-```
-
-Implementation:
-```typescript
-setColumnColor: (columnId, color) => {
- mutate(get, set, (b) => ({
- ...b,
- updatedAt: now(),
- columns: b.columns.map((c) =>
- c.id === columnId ? { ...c, color } : c
- ),
- }));
-},
-```
-
-**Step 5: Verify types compile**
-
-Run: `npx tsc --noEmit`
-Expected: PASS
-
-**Step 6: Commit**
-
-```bash
-git add src/types/board.ts src/lib/schemas.ts src/lib/board-factory.ts src/stores/board-store.ts
-git commit -m "feat: add column color, card coverColor, and board background to data model"
-```
-
----
-
-### Task 8: Column Color UI
-
-**Files:**
-- Modify: `src/components/board/ColumnHeader.tsx`
-- Modify: `src/components/board/KanbanColumn.tsx`
-
-**Step 1: Add Color submenu to ColumnHeader dropdown**
-
-In `ColumnHeader.tsx`, add `setColumnColor` to store selectors:
-
-```typescript
-const setColumnColor = useBoardStore((s) => s.setColumnColor);
-```
-
-Add the color swatches data (same as settings accent presets):
-
-```typescript
-const COLOR_PRESETS = [
- { hue: "160", label: "Teal" },
- { hue: "240", label: "Blue" },
- { hue: "300", label: "Purple" },
- { hue: "350", label: "Pink" },
- { hue: "25", label: "Red" },
- { hue: "55", label: "Orange" },
- { hue: "85", label: "Yellow" },
- { hue: "130", label: "Lime" },
- { hue: "200", label: "Cyan" },
- { hue: "0", label: "Slate" },
-];
-```
-
-Add a Color submenu after the Width submenu in the dropdown:
-
-```tsx
-
- Color
-
- setColumnColor(column.id, null)}>
- None
- {column.color == null && (
- *
- )}
-
-
-
- {COLOR_PRESETS.map(({ hue, label }) => (
- setColumnColor(column.id, hue)}
- className="size-5 rounded-full transition-transform hover:scale-110"
- style={{
- backgroundColor: `oklch(55% 0.12 ${hue})`,
- outline: column.color === hue ? "2px solid currentColor" : "none",
- outlineOffset: "1px",
- }}
- title={label}
- />
- ))}
-
-
-
-```
-
-**Step 2: Use column color for top border**
-
-In the ColumnHeader, update the border-top style to prefer column color over board color:
-
-```typescript
-const effectiveColor = column.color
- ? `oklch(55% 0.12 ${column.color})`
- : boardColor ?? undefined;
-```
-
-And apply:
-```
-style={{ borderTop: effectiveColor ? `3px solid ${effectiveColor}${column.color ? '' : '30'}` : undefined }}
-```
-
-(Full opacity for explicit column colors, 30% for inherited board colors.)
-
-**Step 3: Verify column colors work**
-
-Open a board, right-click column header > Color > pick a color. Verify the top border changes.
-
-**Step 4: Commit**
-
-```bash
-git add src/components/board/ColumnHeader.tsx
-git commit -m "feat: add column color picker submenu with 10 preset colors"
-```
-
----
-
-### Task 9: Card Cover Color
-
-**Files:**
-- Modify: `src/components/board/CardThumbnail.tsx`
-- Modify: `src/components/card-detail/CardDetailModal.tsx`
-
-**Step 1: Render cover color bar in CardThumbnail**
-
-In `CardThumbnail.tsx`, add the cover color bar as the first child inside the ``:
-
-```tsx
-{card.coverColor && (
-
-)}
-```
-
-Note: The negative margins `-mx-3 -mt-3` need to match the card padding. Since we moved to density-based padding, adjust to use `calc(-0.75rem * var(--density-factor))` as inline margin, or simpler — wrap the cover bar outside the padding area. Actually, the simplest approach: put the cover bar before the padded content and use absolute positioning or restructure. Let's use a simpler approach — add the bar at the top with negative margins matching the density padding:
-
-```tsx
-{card.coverColor && (
-
-)}
-```
-
-**Step 2: Add cover color picker to CardDetailModal**
-
-In `CardDetailModal.tsx`, add a "Cover" section to the right sidebar, before Labels:
-
-```tsx
-
-
-```
-
-Create the `CoverColorPicker` component inline in the same file:
-
-```tsx
-function CoverColorPicker({ cardId, coverColor }: { cardId: string; coverColor: string | null }) {
- const updateCard = useBoardStore((s) => s.updateCard);
- const presets = [
- { hue: "160", label: "Teal" }, { hue: "240", label: "Blue" },
- { hue: "300", label: "Purple" }, { hue: "350", label: "Pink" },
- { hue: "25", label: "Red" }, { hue: "55", label: "Orange" },
- { hue: "85", label: "Yellow" }, { hue: "130", label: "Lime" },
- { hue: "200", label: "Cyan" }, { hue: "0", label: "Slate" },
- ];
-
- return (
-
-
- Cover
-
-
- updateCard(cardId, { coverColor: null })}
- className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column"
- title="None"
- >
- ×
-
- {presets.map(({ hue, label }) => (
- updateCard(cardId, { coverColor: hue })}
- className="size-6 rounded-full transition-transform hover:scale-110"
- style={{
- backgroundColor: `oklch(55% 0.12 ${hue})`,
- outline: coverColor === hue ? "2px solid currentColor" : "none",
- outlineOffset: "1px",
- }}
- title={label}
- />
- ))}
-
-
- );
-}
-```
-
-**Step 3: Verify card covers work**
-
-Open card detail > set a cover color > close modal > verify the card thumbnail shows the color bar.
-
-**Step 4: Commit**
-
-```bash
-git add src/components/board/CardThumbnail.tsx src/components/card-detail/CardDetailModal.tsx
-git commit -m "feat: add card cover color with picker in card detail and bar in thumbnail"
-```
-
----
-
-### Task 10: Richer Card Thumbnails
-
-**Files:**
-- Modify: `src/components/board/CardThumbnail.tsx`
-
-**Step 1: Add attachment and description indicators**
-
-Import Paperclip and AlignLeft from lucide-react:
-
-```typescript
-import { Paperclip, AlignLeft } from "lucide-react";
-```
-
-Expand the footer condition to also check for description and attachments:
-
-Replace:
-```tsx
-{(hasDueDate || card.checklist.length > 0) && (
-```
-With:
-```tsx
-{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description) && (
-```
-
-Add the indicators inside the footer div, after the checklist bar:
-
-```tsx
-{card.description && (
-
-)}
-{card.attachments.length > 0 && (
-
-
- {card.attachments.length}
-
-)}
-```
-
-**Step 2: Verify indicators show**
-
-Create a card with a description and attachment. Verify the icons appear in the thumbnail footer.
-
-**Step 3: Commit**
-
-```bash
-git add src/components/board/CardThumbnail.tsx
-git commit -m "feat: add description and attachment indicators to card thumbnails"
-```
-
----
-
-### Task 11: Toast Notification System
-
-**Files:**
-- Create: `src/stores/toast-store.ts`
-- Create: `src/components/toast/ToastContainer.tsx`
-- Modify: `src/App.tsx`
-- Modify: `src/components/import-export/ImportExportButtons.tsx`
-- Modify: `src/components/boards/BoardCard.tsx`
-
-**Step 1: Create toast store**
-
-Create `src/stores/toast-store.ts`:
-
-```typescript
-import { create } from "zustand";
-
-export type ToastType = "success" | "error" | "info";
-
-interface Toast {
- id: string;
- message: string;
- type: ToastType;
-}
-
-interface ToastState {
- toasts: Toast[];
- addToast: (message: string, type?: ToastType) => void;
- removeToast: (id: string) => void;
-}
-
-let nextId = 0;
-
-export const useToastStore = create((set) => ({
- toasts: [],
-
- addToast: (message, type = "info") => {
- const id = String(++nextId);
- set((s) => ({ toasts: [...s.toasts, { id, message, type }] }));
- setTimeout(() => {
- set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
- }, 3000);
- },
-
- removeToast: (id) => {
- set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
- },
-}));
-```
-
-**Step 2: Create ToastContainer component**
-
-Create `src/components/toast/ToastContainer.tsx`:
-
-```typescript
-import { AnimatePresence, motion } from "framer-motion";
-import { useToastStore } from "@/stores/toast-store";
-
-const TYPE_STYLES = {
- success: "bg-pylon-accent/10 text-pylon-accent border-pylon-accent/20",
- error: "bg-pylon-danger/10 text-pylon-danger border-pylon-danger/20",
- info: "bg-pylon-surface text-pylon-text border-border",
-} as const;
-
-export function ToastContainer() {
- const toasts = useToastStore((s) => s.toasts);
-
- return (
-
-
- {toasts.map((toast) => (
-
- {toast.message}
-
- ))}
-
-
- );
-}
-```
-
-**Step 3: Mount ToastContainer in App.tsx**
-
-In `src/App.tsx`, import and add ` ` after the ``:
-
-```typescript
-import { ToastContainer } from "@/components/toast/ToastContainer";
-```
-
-Add inside the return, after ``:
-```tsx
-
-```
-
-**Step 4: Wire up toasts in ImportExportButtons**
-
-In `ImportExportButtons.tsx`, import the toast store:
-
-```typescript
-import { useToastStore } from "@/stores/toast-store";
-```
-
-Add at top of component:
-```typescript
-const addToast = useToastStore((s) => s.addToast);
-```
-
-After successful import (after `addRecentBoard`):
-```typescript
-addToast("Board imported successfully", "success");
-```
-
-In the catch block:
-```typescript
-addToast("Import failed — check file format", "error");
-```
-
-After export calls (`handleExportJson`, `handleExportCsv`), add:
-```typescript
-addToast("Board exported", "success");
-```
-
-**Step 5: Wire up toasts in BoardCard delete**
-
-In `BoardCard.tsx`, import and use toast:
-
-```typescript
-import { useToastStore } from "@/stores/toast-store";
-```
-
-Add in component:
-```typescript
-const addToast = useToastStore((s) => s.addToast);
-```
-
-After `handleDelete` succeeds:
-```typescript
-addToast(`"${board.title}" deleted`, "info");
-```
-
-**Step 6: Verify toasts appear**
-
-Delete a board, export a board, import a board — verify toast pills appear bottom-right and auto-dismiss.
-
-**Step 7: Commit**
-
-```bash
-git add src/stores/toast-store.ts src/components/toast/ToastContainer.tsx src/App.tsx src/components/import-export/ImportExportButtons.tsx src/components/boards/BoardCard.tsx
-git commit -m "feat: add toast notification system with success, error, and info variants"
-```
-
----
-
-### Task 12: Undo/Redo Buttons in TopBar
-
-**Files:**
-- Modify: `src/components/layout/TopBar.tsx`
-
-**Step 1: Add undo/redo buttons**
-
-Import additional icons:
-```typescript
-import { ArrowLeft, Settings, Search, Undo2, Redo2 } from "lucide-react";
-```
-
-Import temporal store access:
-```typescript
-import { useBoardStore } from "@/stores/board-store";
-```
-
-In the right section div (before the saving status span), add:
-
-```tsx
-{isBoardView && (
- <>
-
-
- useBoardStore.temporal.getState().undo()}
- disabled={useBoardStore.temporal.getState().pastStates.length === 0}
- >
-
-
-
-
- Undo Ctrl+Z
-
-
-
-
- useBoardStore.temporal.getState().redo()}
- disabled={useBoardStore.temporal.getState().futureStates.length === 0}
- >
-
-
-
-
- Redo Ctrl+Shift+Z
-
-
- >
-)}
-```
-
-Note: The disabled state won't reactively update since we're reading temporal state imperatively. For reactive disabled state, subscribe to the temporal store. If that's too complex, skip the disabled state for now and always show the buttons enabled.
-
-**Step 2: Verify undo/redo buttons appear and work**
-
-Open a board, make a change, click the undo button, verify it reverts.
-
-**Step 3: Commit**
-
-```bash
-git add src/components/layout/TopBar.tsx
-git commit -m "feat: add undo/redo buttons to TopBar with tooltips"
-```
-
----
-
-### Task 13: Keyboard Shortcut Help Modal
-
-**Files:**
-- Create: `src/components/shortcuts/ShortcutHelpModal.tsx`
-- Modify: `src/hooks/useKeyboardShortcuts.ts`
-- Modify: `src/App.tsx`
-
-**Step 1: Create ShortcutHelpModal**
-
-Create `src/components/shortcuts/ShortcutHelpModal.tsx`:
-
-```typescript
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@/components/ui/dialog";
-
-interface ShortcutHelpModalProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-const SHORTCUT_GROUPS = [
- {
- category: "Navigation",
- shortcuts: [
- { key: "Ctrl+K", description: "Open command palette" },
- { key: "?", description: "Show keyboard shortcuts" },
- { key: "Escape", description: "Close modal / cancel" },
- ],
- },
- {
- category: "Board",
- shortcuts: [
- { key: "Ctrl+Z", description: "Undo" },
- { key: "Ctrl+Shift+Z", description: "Redo" },
- ],
- },
-];
-
-export function ShortcutHelpModal({ open, onOpenChange }: ShortcutHelpModalProps) {
- return (
-
-
-
-
- Keyboard Shortcuts
-
-
- Quick reference for all keyboard shortcuts.
-
-
-
-
- {SHORTCUT_GROUPS.map((group) => (
-
-
- {group.category}
-
-
- {group.shortcuts.map(({ key, description }) => (
-
- {description}
-
- {key}
-
-
- ))}
-
-
- ))}
-
-
-
- );
-}
-```
-
-**Step 2: Add `?` key handler to useKeyboardShortcuts**
-
-In `useKeyboardShortcuts.ts`, after the Escape handler, add:
-
-```typescript
-// ? : open keyboard shortcut help
-if (e.key === "?" || (e.shiftKey && e.key === "/")) {
- e.preventDefault();
- document.dispatchEvent(new CustomEvent("open-shortcut-help"));
- return;
-}
-```
-
-**Step 3: Wire up in App.tsx**
-
-In `App.tsx`, add state and listener:
-
-```typescript
-import { ShortcutHelpModal } from "@/components/shortcuts/ShortcutHelpModal";
-```
-
-Add state:
-```typescript
-const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
-```
-
-Add event listener (in the existing useEffect pattern or a new one):
-```typescript
-useEffect(() => {
- function handleOpenShortcutHelp() {
- setShortcutHelpOpen(true);
- }
- document.addEventListener("open-shortcut-help", handleOpenShortcutHelp);
- return () => {
- document.removeEventListener("open-shortcut-help", handleOpenShortcutHelp);
- };
-}, []);
-```
-
-Add the modal to the return:
-```tsx
-
-```
-
-**Step 4: Verify ? key opens the modal**
-
-Press `?` on a board view (not in an input) — verify the shortcuts modal appears.
-
-**Step 5: Commit**
-
-```bash
-git add src/components/shortcuts/ShortcutHelpModal.tsx src/hooks/useKeyboardShortcuts.ts src/App.tsx
-git commit -m "feat: add keyboard shortcut help modal triggered by ? key"
-```
-
----
-
-### Task 14: Board Backgrounds
-
-**Files:**
-- Modify: `src/components/board/BoardView.tsx`
-- Modify: `src/components/layout/TopBar.tsx`
-
-**Step 1: Add background patterns to BoardView**
-
-In `BoardView.tsx`, compute the background CSS based on board settings:
-
-Add a helper function before the component:
-
-```typescript
-function getBoardBackground(board: Board): React.CSSProperties {
- const bg = board.settings.background;
- if (bg === "dots") {
- return {
- backgroundImage: `radial-gradient(circle, currentColor 1px, transparent 1px)`,
- backgroundSize: "20px 20px",
- color: "oklch(50% 0 0 / 5%)",
- };
- }
- if (bg === "grid") {
- return {
- backgroundImage: `linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)`,
- backgroundSize: "24px 24px",
- color: "oklch(50% 0 0 / 5%)",
- };
- }
- if (bg === "gradient") {
- return {
- background: `linear-gradient(135deg, ${board.color}08, ${board.color}03, transparent)`,
- };
- }
- return {};
-}
-```
-
-Apply it to the board container div:
-
-```tsx
-
-```
-
-**Step 2: Add board settings gear button in TopBar**
-
-In `TopBar.tsx`, import `Settings2` from lucide-react (or use a gear icon) and add a board settings dropdown.
-
-For simplicity, add a button that dispatches a custom event to open a board settings popover. Or implement it directly as a dropdown in TopBar:
-
-```tsx
-{isBoardView && board && (
-
-
-
-
-
-
-
-
- Background
-
- {(["none", "dots", "grid", "gradient"] as const).map((bg) => (
- useBoardStore.getState().updateBoardSettings({ ...board.settings, background: bg })}
- >
- {bg.charAt(0).toUpperCase() + bg.slice(1)}
- {board.settings.background === bg && (
- *
- )}
-
- ))}
-
-
-
-
-)}
-```
-
-Import necessary dropdown components and `Sliders` icon from lucide-react in TopBar.
-
-**Step 3: Verify backgrounds render**
-
-Open board settings > Background > pick "Dots". Verify the subtle dot pattern appears behind columns.
-
-**Step 4: Commit**
-
-```bash
-git add src/components/board/BoardView.tsx src/components/layout/TopBar.tsx
-git commit -m "feat: add board background patterns (dots, grid, gradient) with settings dropdown"
-```
-
----
-
-### Task 15: Onboarding / Empty States
-
-**Files:**
-- Modify: `src/components/boards/BoardList.tsx`
-- Modify: `src/components/board/KanbanColumn.tsx`
-
-**Step 1: Upgrade empty board list state**
-
-In `BoardList.tsx`, replace the empty state block:
-
-```tsx
-if (boards.length === 0) {
- return (
- <>
-
-
-
- Welcome to OpenPylon
-
-
- A local-first Kanban board that keeps your data on your machine.
- Create your first board to get started.
-
-
-
-
setDialogOpen(true)}>
-
- Create Board
-
-
-
-
-
- >
- );
-}
-```
-
-**Step 2: Add empty column state**
-
-In `KanbanColumn.tsx`, when there are no cards, show a dashed placeholder:
-
-After the card list ``, but still inside the `
`, check for empty:
-
-Actually, the best approach is inside the `` — when `column.cardIds.length === 0`, render a placeholder ``:
-
-After `{column.cardIds.map(...)}`, add:
-
-```tsx
-{column.cardIds.length === 0 && (
-
- Drop or add a card
-
-)}
-```
-
-**Step 3: Verify empty states**
-
-Delete all boards — verify the welcome message. Create a board with blank template — verify empty columns show "Drop or add a card".
-
-**Step 4: Commit**
-
-```bash
-git add src/components/boards/BoardList.tsx src/components/board/KanbanColumn.tsx
-git commit -m "feat: upgrade empty states with welcome message and column placeholders"
-```
-
----
-
-### Task 16: Polish Pass
-
-**Files:**
-- Modify: `src/index.css`
-- Various touch-ups
-
-**Step 1: Add themed scrollbar styles**
-
-In `src/index.css`, add inside `@layer base`:
-
-```css
-/* Thin themed scrollbars */
-* {
- scrollbar-width: thin;
- scrollbar-color: oklch(50% 0 0 / 20%) transparent;
-}
-.dark * {
- scrollbar-color: oklch(80% 0 0 / 15%) transparent;
-}
-```
-
-**Step 2: Verify dark mode works with all new features**
-
-Toggle to dark mode, verify:
-- Accent colors look correct
-- Column color borders visible
-- Card cover bars visible
-- Background patterns visible (dots, grid, gradient)
-- Toasts styled correctly
-
-**Step 3: Verify zoom works at extremes**
-
-Set zoom to 75% and 150%, verify:
-- No layout breakage
-- Columns still have proper widths
-- Card thumbnails still readable
-
-**Step 4: Commit**
-
-```bash
-git add src/index.css
-git commit -m "feat: add themed scrollbar styling and verify polish across modes"
-```
-
----
-
-### Task 17: Fix Capabilities & Final Verification
-
-**Files:**
-- Modify: `src-tauri/capabilities/default.json` (if not already fixed)
-
-**Step 1: Ensure capabilities use Tauri v2 named permissions**
-
-Verify the file contains:
-```json
-{
- "$schema": "../gen/schemas/desktop-schema.json",
- "identifier": "default",
- "description": "Default permissions for OpenPylon",
- "windows": ["main"],
- "permissions": [
- "core:default",
- "opener:default",
- "dialog:default",
- "shell:default",
- "fs:default",
- "fs:allow-appdata-read-recursive",
- "fs:allow-appdata-write-recursive",
- "fs:allow-appdata-meta-recursive"
- ]
-}
-```
-
-**Step 2: Run the full app**
-
-Run: `npm run tauri dev`
-
-Verify end-to-end:
-1. Create a board
-2. Add columns and cards
-3. Set column colors
-4. Set card cover colors
-5. Change board background
-6. Toggle density and zoom in settings
-7. Change accent color
-8. Press `?` for shortcut help
-9. Use undo/redo buttons
-10. Export and import a board — verify toasts
-11. Delete a board — verify toast
-12. Toggle light/dark mode
-
-**Step 3: Final commit**
-
-```bash
-git add -A
-git commit -m "feat: complete visual glow-up — settings, zoom, accent, density, colors, toasts, backgrounds"
-```
-
----
-
-## Summary
-
-| Task | Description | Files |
-|------|-------------|-------|
-| 1 | Expand settings type & schema | settings.ts, schemas.ts |
-| 2 | Wire app store with appearance | app-store.ts |
-| 3 | Add density CSS variable | index.css |
-| 4 | Rewrite settings dialog (tabbed) | SettingsDialog.tsx |
-| 5 | Apply density to cards/columns | KanbanColumn, CardThumbnail, BoardView |
-| 6 | Board color in UI | TopBar, ColumnHeader, KanbanColumn |
-| 7 | Expand board types | board.ts, schemas.ts, board-factory.ts, board-store.ts |
-| 8 | Column color UI | ColumnHeader |
-| 9 | Card cover color | CardThumbnail, CardDetailModal |
-| 10 | Richer card thumbnails | CardThumbnail |
-| 11 | Toast notification system | toast-store.ts, ToastContainer, App, ImportExport, BoardCard |
-| 12 | Undo/redo buttons | TopBar |
-| 13 | Keyboard shortcut help modal | ShortcutHelpModal, useKeyboardShortcuts, App |
-| 14 | Board backgrounds | BoardView, TopBar |
-| 15 | Onboarding / empty states | BoardList, KanbanColumn |
-| 16 | Polish pass | index.css |
-| 17 | Fix capabilities & final test | capabilities/default.json |
diff --git a/docs/plans/2026-02-16-15-improvements-design.md b/docs/plans/2026-02-16-15-improvements-design.md
deleted file mode 100644
index 92b20e1..0000000
--- a/docs/plans/2026-02-16-15-improvements-design.md
+++ /dev/null
@@ -1,296 +0,0 @@
-# OpenPylon: 15 Improvements Design
-
-## Overview
-
-15 improvements to OpenPylon organized into 5 phases (0-4), designed for incremental delivery. Each phase builds on the previous. You can ship after any phase and have a coherent improvement.
-
-## Decisions
-
-- **Phasing**: 4 phases (quick wins first, progressively bigger features)
-- **Data compatibility**: New fields use Zod `.default()` values. No migration code. Old boards load cleanly.
-- **Templates storage**: JSON files in `data/templates/`
-- **Backup storage**: Timestamped files in `data/backups/{boardId}/`, keep last 10
-
----
-
-## Phase 0: Data Model Foundation
-
-All type/schema changes that later features depend on. Done first so everything builds on a stable base.
-
-### Card type additions
-
-```typescript
-// Added to Card interface + cardSchema
-priority: "none" | "low" | "medium" | "high" | "urgent"; // default: "none"
-comments: Comment[]; // default: []
-
-// New type + schema
-interface Comment {
- id: string;
- text: string;
- createdAt: string;
-}
-```
-
-### Column type additions
-
-```typescript
-// Added to Column interface + columnSchema
-collapsed: boolean; // default: false
-wipLimit: number | null; // default: null
-```
-
-### Files touched
-
-- `src/types/board.ts` — Add fields to Card, Column interfaces. Add Comment interface.
-- `src/lib/schemas.ts` — Add Zod fields with defaults to cardSchema, columnSchema. Add commentSchema.
-
----
-
-## Phase 1: Quick Wins
-
-Minimal changes, high value. ~4 files each.
-
-### #8 — Consume defaultColumnWidth Setting
-
-In `board-store.ts`, `addColumn` reads `useAppStore.getState().settings.defaultColumnWidth` instead of hardcoding `"standard"`.
-
-**Files**: `src/stores/board-store.ts` (1 line change)
-
-### #4 — Due Date Visual Indicators
-
-Replace binary overdue/not logic in `CardThumbnail` with 4-tier color system:
-
-| Status | Condition | Color |
-|--------|-----------|-------|
-| Overdue | past + not today | `pylon-danger` (red) |
-| Approaching | due within 2 days | amber `oklch(65% 0.15 70)` |
-| Comfortable | due but >2 days | green `oklch(55% 0.12 145)` |
-| No date | null | `pylon-text-secondary` (gray) |
-
-Helper function `getDueDateStatus(dueDate: string | null)` returns `{ color, label }`.
-
-**Files**: `src/components/board/CardThumbnail.tsx`
-
-### #9 — Card Aging Visualization
-
-Compute days since `card.updatedAt`. Apply opacity:
-
-| Days stale | Opacity |
-|------------|---------|
-| 0-7 | 1.0 |
-| 7-14 | 0.85 |
-| 14-30 | 0.7 |
-| 30+ | 0.55 |
-
-Applied as inline `opacity` on the card `motion.button`.
-
-**Files**: `src/components/board/CardThumbnail.tsx`
-
-### #12 — Open Attachments
-
-Add "Open" button to each attachment in `AttachmentSection`. Uses `open()` from `@tauri-apps/plugin-opener` (already registered).
-
-**Files**: `src/components/card-detail/AttachmentSection.tsx`
-
----
-
-## Phase 2: Card Interactions & UI Enhancements
-
-5 features that transform how cards feel to use.
-
-### #2 — Card Priority Levels
-
-**Thumbnail indicator**: Colored dot in footer row. Color map:
-- `none`: hidden
-- `low`: blue
-- `medium`: yellow
-- `high`: orange
-- `urgent`: red with pulse animation
-
-**Detail modal**: Priority picker section in left column (like LabelPicker). Row of 5 clickable chips with colors.
-
-**Files**: `CardThumbnail.tsx`, `CardDetailModal.tsx` (new PriorityPicker component inline or separate), `board-store.ts` (no new action needed — `updateCard` handles it)
-
-### #5 — Card Context Menu
-
-Wrap `CardThumbnail` in Radix `ContextMenu`.
-
-**Menu items**:
-- Move to → (submenu listing columns except current)
-- Set priority → (submenu with 5 options)
-- Duplicate (new card with same fields, `(copy)` suffix, new ID, inserted below original)
-- Separator
-- Delete (confirmation dialog)
-
-**New store action**: `duplicateCard(cardId): string` — clones card, inserts after original in same column.
-
-**Files**: `CardThumbnail.tsx`, `board-store.ts`
-
-### #10 — WIP Limits
-
-**Column header display**: Shows `3/5` when wipLimit set. Background tint:
-- Under limit: normal
-- At limit: amber tint `oklch(75% 0.08 70 / 15%)`
-- Over limit: red tint `oklch(70% 0.08 25 / 15%)`
-
-**Setting UI**: New "Set WIP Limit" item in ColumnHeader dropdown menu. Preset choices: None / 3 / 5 / 7 / 10 / Custom.
-
-**New store action**: `setColumnWipLimit(columnId: string, limit: number | null)`
-
-**Files**: `ColumnHeader.tsx`, `KanbanColumn.tsx`, `board-store.ts`
-
-### #3 — Column Collapse/Expand
-
-When `collapsed`, render a 40px-wide strip instead of full column:
-- Vertical text via `writing-mode: vertical-rl; rotate: 180deg`
-- Card count badge
-- Click to expand
-
-Animate width from full to 40px using existing `animate={{ width }}` on outer `motion.div` with `springs.bouncy`.
-
-**New store action**: `toggleColumnCollapse(columnId: string)`
-
-**Collapse button**: Added to ColumnHeader dropdown menu + a small chevron icon on the collapsed strip.
-
-**Files**: `KanbanColumn.tsx`, `ColumnHeader.tsx`, `board-store.ts`
-
-### #11 — Checklist Item Reordering
-
-Wrap checklist `` in `ChecklistSection` with `DndContext` + `SortableContext` (vertical strategy). Each `ChecklistRow` becomes sortable.
-
-**Drag handle**: `GripVertical` icon on left of each item, visible on hover.
-
-**Drop indicator**: Horizontal glow line (same as card drag — vertical list so horizontal line is correct).
-
-**New store action**: `reorderChecklistItems(cardId: string, fromIndex: number, toIndex: number)`
-
-**Files**: `ChecklistSection.tsx`, `board-store.ts`
-
----
-
-## Phase 3: Navigation & Power User Features
-
-Features that make power users fall in love.
-
-### #1 — Card Filtering & Quick Search
-
-**Filter bar**: Slides down below TopBar. Triggered by filter icon in TopBar or `/` keyboard shortcut.
-
-**Filter controls** (horizontal row):
-- Text input (debounced 200ms, title search)
-- Label multi-select dropdown (ANY match)
-- Due date dropdown: All / Overdue / Due this week / Due today / No date
-- Priority dropdown: All / Urgent / High / Medium / Low
-- Clear all button
-
-**State**: Local state in `BoardView` (not persisted — filters are ephemeral).
-
-**Rendering**: `KanbanColumn` receives filtered card IDs. Non-matching cards fade out. Column counts show `3 of 7` when filtering.
-
-**Files**: New `FilterBar.tsx` component, `BoardView.tsx`, `KanbanColumn.tsx`, `TopBar.tsx` (filter button)
-
-### #7 — Keyboard Card Navigation
-
-**State**: `focusedCardId` in `BoardView` local state.
-
-**Key bindings** (when no input/textarea focused):
-| Key | Action |
-|-----|--------|
-| `J` / `ArrowDown` | Focus next card in column |
-| `K` / `ArrowUp` | Focus previous card in column |
-| `H` / `ArrowLeft` | Focus same-index card in previous column |
-| `L` / `ArrowRight` | Focus same-index card in next column |
-| `Enter` | Open focused card detail |
-| `Escape` | Clear focus / close modal |
-
-**Visual**: Focused card gets `ring-2 ring-pylon-accent ring-offset-2`. Column auto-scrolls via `scrollIntoView({ block: "nearest" })`.
-
-**Implementation**: `useKeyboardNavigation` hook. Passes `isFocused` through `KanbanColumn` to `CardThumbnail`.
-
-**Files**: New `useKeyboardNavigation.ts` hook, `BoardView.tsx`, `KanbanColumn.tsx`, `CardThumbnail.tsx`
-
-### #6 — Desktop Notifications for Due Dates
-
-**Plugin**: Add `tauri-plugin-notification` to `Cargo.toml` and capabilities.
-
-**Trigger**: On `useAppStore.init()`, after loading boards, scan all cards:
-- Cards due today → "You have X cards due today"
-- Cards overdue → "You have X overdue cards"
-
-Batched (one notification per category). Store `lastNotificationCheck` in settings to skip if checked within last hour.
-
-**Files**: `src-tauri/Cargo.toml`, `src-tauri/capabilities/default.json`, `src/stores/app-store.ts`, `src/types/settings.ts` (add `lastNotificationCheck`), `src/lib/schemas.ts`
-
-### #13 — Card Comments / Activity Log
-
-**UI in CardDetailModal**: New section in right column below description.
-- Scrollable comment list (newest first)
-- Each: text, relative timestamp, delete button (hover)
-- Add input: textarea + "Add" button. Enter submits, Shift+Enter newline.
-
-**Store actions**: `addComment(cardId, text)`, `deleteComment(cardId, commentId)`. Comments get ULID IDs and `createdAt`.
-
-**Files**: New `CommentsSection.tsx`, `CardDetailModal.tsx`, `board-store.ts`
-
----
-
-## Phase 4: System Features & Infrastructure
-
-Deeper features touching storage and templates.
-
-### #14 — Board Templates & Saved Structures
-
-**Template type**:
-```typescript
-interface BoardTemplate {
- id: string;
- name: string;
- color: string;
- columns: { title: string; width: ColumnWidth; color: string | null; wipLimit: number | null }[];
- labels: Label[];
- settings: BoardSettings;
-}
-```
-
-**Saving**: Context menu item on `BoardCard` — "Save as Template". Prompts for name. Strips cards/timestamps. Writes to `data/templates/{id}.json`.
-
-**Creating**: `NewBoardDialog` shows built-in templates (Blank, Kanban, Sprint) + user templates below a separator. Delete button (X) on user templates.
-
-**Storage functions**: `listTemplates()`, `saveTemplate()`, `deleteTemplate()` in `storage.ts`. `board-factory.ts` gets `createBoardFromTemplate()`.
-
-**Files**: `storage.ts`, `board-factory.ts`, `NewBoardDialog.tsx`, `BoardCard.tsx`, new `src/types/template.ts`
-
-### #15 — Auto-Backup & Version History
-
-**Storage**: `data/backups/{boardId}/` directory. Timestamped files: `{boardId}-{ISO timestamp}.json`.
-
-**Save flow** in `board-store.ts`:
-1. Read current file as previous version
-2. Write new board to `{boardId}.json`
-3. Write previous version to `data/backups/{boardId}/{boardId}-{timestamp}.json`
-4. Prune backups beyond 10
-
-**UI — Version History dialog**: Accessible from board settings dropdown menu ("Version History"). Shows:
-- List of backups sorted newest-first
-- Each entry: relative timestamp, card count, column count
-- "Restore" button with confirmation dialog
-- Current board auto-backed-up before restore (restore is reversible)
-
-**Storage functions**: `listBackups(boardId)`, `restoreBackup(boardId, filename)`, `pruneBackups(boardId, keep)`.
-
-**Files**: `storage.ts`, `board-store.ts`, new `VersionHistoryDialog.tsx`, `TopBar.tsx` (menu item)
-
----
-
-## Dependency Graph
-
-```
-Phase 0 (data model)
- └── Phase 1 (quick wins) — no deps on Phase 0 except #8
- └── Phase 2 (card interactions) — needs priority + collapsed + wipLimit from Phase 0
- └── Phase 3 (power user) — needs priority for filtering, context menu patterns from Phase 2
- └── Phase 4 (infrastructure) — needs wipLimit in templates from Phase 0+2
-```
-
-Phase 1 can actually run in parallel with Phase 0 since its features don't touch the new fields. Phases 2-4 are strictly sequential.
diff --git a/docs/plans/2026-02-16-15-improvements-implementation.md b/docs/plans/2026-02-16-15-improvements-implementation.md
deleted file mode 100644
index 2789907..0000000
--- a/docs/plans/2026-02-16-15-improvements-implementation.md
+++ /dev/null
@@ -1,2474 +0,0 @@
-# 15 Improvements Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Implement 15 improvements to OpenPylon kanban app across 5 phases, from data model changes through infrastructure features.
-
-**Architecture:** All features follow existing patterns: Zustand store with `mutate(get, set, (b) => ...)` for board mutations, Zod schemas with `.default()` for backwards-compatible data evolution, Radix UI for menus/dialogs, Framer Motion for animations, OKLCH colors, Tailwind CSS 4.
-
-**Tech Stack:** Tauri v2, React 19, TypeScript, Zustand 5 (with zundo temporal), Tailwind CSS 4, Radix UI, dnd-kit, Framer Motion, date-fns, ulid
-
-**Note:** This project has no test framework. Verification is done by running `npm run tauri dev` and manually testing each feature.
-
----
-
-## Phase 0: Data Model Foundation
-
-All type/schema changes that later features depend on.
-
----
-
-### Task 1: Add Comment type and schema
-
-**Files:**
-- Modify: `src/types/board.ts`
-- Modify: `src/lib/schemas.ts`
-
-**Step 1: Add Comment interface to types**
-
-In `src/types/board.ts`, add after the `ChecklistItem` interface (line 46):
-
-```typescript
-export interface Comment {
- id: string;
- text: string;
- createdAt: string;
-}
-```
-
-**Step 2: Add commentSchema to schemas**
-
-In `src/lib/schemas.ts`, add after `checklistItemSchema` (line 7):
-
-```typescript
-export const commentSchema = z.object({
- id: z.string(),
- text: z.string(),
- createdAt: z.string(),
-});
-```
-
-**Step 3: Verify**
-
-Run: `npx tsc --noEmit`
-Expected: No errors
-
-**Step 4: Commit**
-
-```
-git add src/types/board.ts src/lib/schemas.ts
-git commit -m "feat: add Comment type and schema"
-```
-
----
-
-### Task 2: Add priority and comments fields to Card
-
-**Files:**
-- Modify: `src/types/board.ts`
-- Modify: `src/lib/schemas.ts`
-
-**Step 1: Add Priority type and update Card interface**
-
-In `src/types/board.ts`, add before the `Card` interface:
-
-```typescript
-export type Priority = "none" | "low" | "medium" | "high" | "urgent";
-```
-
-Add two fields to the `Card` interface (after `coverColor`):
-
-```typescript
-priority: Priority;
-comments: Comment[];
-```
-
-**Step 2: Add fields to cardSchema**
-
-In `src/lib/schemas.ts`, add to `cardSchema` (after `coverColor` line):
-
-```typescript
-priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
-comments: z.array(commentSchema).default([]),
-```
-
-**Step 3: Update addCard in board-store.ts**
-
-In `src/stores/board-store.ts`, update the `addCard` action's card creation (around line 198-209) to include new fields:
-
-```typescript
-const card: Card = {
- id: cardId,
- title,
- description: "",
- labels: [],
- checklist: [],
- dueDate: null,
- attachments: [],
- coverColor: null,
- priority: "none",
- comments: [],
- createdAt: now(),
- updatedAt: now(),
-};
-```
-
-Also update the import from `@/types/board` to include `Priority`.
-
-**Step 4: Verify**
-
-Run: `npx tsc --noEmit`
-Expected: No errors
-
-**Step 5: Commit**
-
-```
-git add src/types/board.ts src/lib/schemas.ts src/stores/board-store.ts
-git commit -m "feat: add priority and comments fields to Card"
-```
-
----
-
-### Task 3: Add collapsed and wipLimit fields to Column
-
-**Files:**
-- Modify: `src/types/board.ts`
-- Modify: `src/lib/schemas.ts`
-
-**Step 1: Update Column interface**
-
-In `src/types/board.ts`, add two fields to the `Column` interface (after `color`):
-
-```typescript
-collapsed: boolean;
-wipLimit: number | null;
-```
-
-**Step 2: Update columnSchema**
-
-In `src/lib/schemas.ts`, add to `columnSchema` (after `color` line):
-
-```typescript
-collapsed: z.boolean().default(false),
-wipLimit: z.number().nullable().default(null),
-```
-
-**Step 3: Update addColumn in board-store.ts**
-
-In `src/stores/board-store.ts`, update the column creation in `addColumn` (around line 130-136) to include new fields:
-
-```typescript
-{
- id: ulid(),
- title,
- cardIds: [],
- width: "standard" as ColumnWidth,
- color: null,
- collapsed: false,
- wipLimit: null,
-}
-```
-
-**Step 4: Update board-factory.ts**
-
-In `src/lib/board-factory.ts`, update the `col` helper (around line 24-30) to include new fields:
-
-```typescript
-const col = (t: string, w: ColumnWidth = "standard") => ({
- id: ulid(),
- title: t,
- cardIds: [] as string[],
- width: w,
- color: null as string | null,
- collapsed: false,
- wipLimit: null as number | null,
-});
-```
-
-**Step 5: Verify**
-
-Run: `npx tsc --noEmit`
-Expected: No errors
-
-**Step 6: Commit**
-
-```
-git add src/types/board.ts src/lib/schemas.ts src/stores/board-store.ts src/lib/board-factory.ts
-git commit -m "feat: add collapsed and wipLimit fields to Column"
-```
-
----
-
-## Phase 1: Quick Wins
-
-Minimal changes, high value.
-
----
-
-### Task 4: #8 — Consume defaultColumnWidth setting
-
-**Files:**
-- Modify: `src/stores/board-store.ts`
-
-**Step 1: Read setting in addColumn**
-
-In `src/stores/board-store.ts`, update `addColumn` to read the setting. Replace the hardcoded `"standard"`:
-
-```typescript
-addColumn: (title: string) => {
- const defaultWidth = useAppStore.getState().settings.defaultColumnWidth;
- mutate(get, set, (b) => ({
- ...b,
- updatedAt: now(),
- columns: [
- ...b.columns,
- {
- id: ulid(),
- title,
- cardIds: [],
- width: defaultWidth,
- color: null,
- collapsed: false,
- wipLimit: null,
- },
- ],
- }));
-},
-```
-
-Add import at top of file:
-
-```typescript
-import { useAppStore } from "@/stores/app-store";
-```
-
-**Step 2: Verify**
-
-Run `npm run tauri dev`. Change default column width in Settings, add a new column. It should use the selected width.
-
-**Step 3: Commit**
-
-```
-git add src/stores/board-store.ts
-git commit -m "feat: addColumn reads defaultColumnWidth from settings"
-```
-
----
-
-### Task 5: #4 — Due date visual indicators
-
-**Files:**
-- Modify: `src/components/board/CardThumbnail.tsx`
-
-**Step 1: Add getDueDateStatus helper and update rendering**
-
-In `src/components/board/CardThumbnail.tsx`, replace the existing due date logic (lines 36-38) and the due date rendering in the footer (lines 109-119).
-
-Add this helper function before the `CardThumbnail` component:
-
-```typescript
-function getDueDateStatus(dueDate: string | null): { color: string; bgColor: string; label: string } | null {
- if (!dueDate) return null;
- const date = new Date(dueDate);
- const now = new Date();
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- const dueDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
- const diffDays = Math.ceil((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
-
- if (diffDays < 0) {
- return { color: "text-pylon-danger", bgColor: "bg-pylon-danger/10", label: "Overdue" };
- }
- if (diffDays <= 2) {
- return { color: "text-[oklch(65%_0.15_70)]", bgColor: "bg-[oklch(65%_0.15_70/10%)]", label: "Due soon" };
- }
- return { color: "text-[oklch(55%_0.12_145)]", bgColor: "bg-[oklch(55%_0.12_145/10%)]", label: "Upcoming" };
-}
-```
-
-Remove the old `overdue` variable and `isPast`/`isToday` imports (keep `format` from date-fns). Replace the due date span in the footer row:
-
-```typescript
-{card.dueDate && (() => {
- const status = getDueDateStatus(card.dueDate);
- if (!status) return null;
- return (
-
- {format(new Date(card.dueDate), "MMM d")}
-
- );
-})()}
-```
-
-**Step 2: Clean up imports**
-
-Remove `isPast, isToday` from the date-fns import since they're no longer needed.
-
-**Step 3: Verify**
-
-Run `npm run tauri dev`. Create cards with due dates: past dates should be red, dates within 2 days should be amber, dates further out should be green.
-
-**Step 4: Commit**
-
-```
-git add src/components/board/CardThumbnail.tsx
-git commit -m "feat: color-coded due date indicators (red/amber/green)"
-```
-
----
-
-### Task 6: #9 — Card aging visualization
-
-**Files:**
-- Modify: `src/components/board/CardThumbnail.tsx`
-
-**Step 1: Add aging opacity helper**
-
-Add this helper near `getDueDateStatus`:
-
-```typescript
-function getAgingOpacity(updatedAt: string): number {
- const days = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);
- if (days <= 7) return 1.0;
- if (days <= 14) return 0.85;
- if (days <= 30) return 0.7;
- return 0.55;
-}
-```
-
-**Step 2: Apply opacity to card**
-
-In the `motion.button` element, add `opacity` to the `style` prop:
-
-```typescript
-style={{
- transform: CSS.Transform.toString(transform),
- transition,
- padding: `calc(0.75rem * var(--density-factor))`,
- opacity: getAgingOpacity(card.updatedAt),
-}}
-```
-
-**Step 3: Verify**
-
-Run `npm run tauri dev`. Cards updated recently should be fully opaque. Old cards should appear faded.
-
-**Step 4: Commit**
-
-```
-git add src/components/board/CardThumbnail.tsx
-git commit -m "feat: card aging visualization - stale cards fade"
-```
-
----
-
-### Task 7: #12 — Open attachments
-
-**Files:**
-- Modify: `src/components/card-detail/AttachmentSection.tsx`
-
-**Step 1: Add Open button to each attachment**
-
-Import the opener and add an icon:
-
-```typescript
-import { openPath } from "@tauri-apps/plugin-opener";
-import { FileIcon, X, Plus, ExternalLink } from "lucide-react";
-```
-
-In the attachment row (inside the `.map`), add an open button before the remove button:
-
-```typescript
- openPath(att.path)}
- className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-accent/10 hover:text-pylon-accent group-hover/att:opacity-100"
- aria-label="Open attachment"
->
-
-
-```
-
-**Step 2: Verify**
-
-Run `npm run tauri dev`. Add an attachment to a card. The "Open" icon should appear on hover and open the file with the system default application.
-
-**Step 3: Commit**
-
-```
-git add src/components/card-detail/AttachmentSection.tsx
-git commit -m "feat: open attachments with system default app"
-```
-
----
-
-## Phase 2: Card Interactions & UI Enhancements
-
----
-
-### Task 8: #2 — Card priority levels (thumbnail indicator)
-
-**Files:**
-- Modify: `src/components/board/CardThumbnail.tsx`
-
-**Step 1: Add priority color map and dot**
-
-Add constant near top of file:
-
-```typescript
-const PRIORITY_COLORS: Record = {
- low: "oklch(60% 0.15 240)", // blue
- medium: "oklch(70% 0.15 85)", // yellow
- high: "oklch(60% 0.15 55)", // orange
- urgent: "oklch(55% 0.15 25)", // red
-};
-```
-
-In the footer row (the `div` with `mt-2 flex items-center gap-3`), add priority dot at the start:
-
-```typescript
-{card.priority !== "none" && (
-
-)}
-```
-
-Also update the footer row's show condition to include priority:
-
-```typescript
-{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
-```
-
-**Step 2: Verify**
-
-Run `npm run tauri dev`. Set priorities on cards (we'll add the picker in the next task). For now verify with no TypeScript errors: `npx tsc --noEmit`.
-
-**Step 3: Commit**
-
-```
-git add src/components/board/CardThumbnail.tsx
-git commit -m "feat: priority dot indicator on card thumbnails"
-```
-
----
-
-### Task 9: #2 — Card priority levels (detail modal picker)
-
-**Files:**
-- Create: `src/components/card-detail/PriorityPicker.tsx`
-- Modify: `src/components/card-detail/CardDetailModal.tsx`
-
-**Step 1: Create PriorityPicker component**
-
-Create `src/components/card-detail/PriorityPicker.tsx`:
-
-```typescript
-import { useBoardStore } from "@/stores/board-store";
-import type { Priority } from "@/types/board";
-
-const PRIORITIES: { value: Priority; label: string; color: string }[] = [
- { value: "none", label: "None", color: "oklch(50% 0 0 / 30%)" },
- { value: "low", label: "Low", color: "oklch(60% 0.15 240)" },
- { value: "medium", label: "Medium", color: "oklch(70% 0.15 85)" },
- { value: "high", label: "High", color: "oklch(60% 0.15 55)" },
- { value: "urgent", label: "Urgent", color: "oklch(55% 0.15 25)" },
-];
-
-interface PriorityPickerProps {
- cardId: string;
- priority: Priority;
-}
-
-export function PriorityPicker({ cardId, priority }: PriorityPickerProps) {
- const updateCard = useBoardStore((s) => s.updateCard);
-
- return (
-
-
- Priority
-
-
- {PRIORITIES.map(({ value, label, color }) => (
- updateCard(cardId, { priority: value })}
- className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
- priority === value
- ? "ring-2 ring-offset-1 ring-offset-pylon-column/50 text-white"
- : "text-pylon-text-secondary hover:text-pylon-text"
- }`}
- style={{
- backgroundColor: priority === value ? color : undefined,
- borderColor: color,
- border: priority !== value ? `1px solid ${color}` : undefined,
- ringColor: color,
- }}
- >
- {label}
-
- ))}
-
-
- );
-}
-```
-
-**Step 2: Add PriorityPicker to CardDetailModal**
-
-In `src/components/card-detail/CardDetailModal.tsx`, import the new component:
-
-```typescript
-import { PriorityPicker } from "@/components/card-detail/PriorityPicker";
-```
-
-Add a new grid cell for Priority. Insert it in the dashboard grid — add it as a new row before the Cover color section. Replace the Row 3 comment block (Cover + Attachments) with:
-
-```typescript
-{/* Row 3: Priority + Cover */}
-
-
-
-
-
-
-
-
-{/* Row 4: Attachments (full width) */}
-
-
-
-```
-
-**Step 3: Verify**
-
-Run `npm run tauri dev`. Open a card detail. Priority picker should show 5 chips. Click one — the card thumbnail should show the corresponding colored dot.
-
-**Step 4: Commit**
-
-```
-git add src/components/card-detail/PriorityPicker.tsx src/components/card-detail/CardDetailModal.tsx
-git commit -m "feat: priority picker in card detail modal"
-```
-
----
-
-### Task 10: #5 — Card context menu
-
-**Files:**
-- Modify: `src/components/board/CardThumbnail.tsx`
-- Modify: `src/stores/board-store.ts`
-
-**Step 1: Add duplicateCard store action**
-
-In `src/stores/board-store.ts`, add to `BoardActions` interface:
-
-```typescript
-duplicateCard: (cardId: string) => string | null;
-```
-
-Add implementation after `deleteCard`:
-
-```typescript
-duplicateCard: (cardId) => {
- const { board } = get();
- if (!board) return null;
- const original = board.cards[cardId];
- if (!original) return null;
-
- const column = board.columns.find((c) => c.cardIds.includes(cardId));
- if (!column) return null;
-
- const newId = ulid();
- const ts = now();
- const clone: Card = {
- ...original,
- id: newId,
- title: `${original.title} (copy)`,
- comments: [],
- createdAt: ts,
- updatedAt: ts,
- };
-
- const insertIndex = column.cardIds.indexOf(cardId) + 1;
-
- mutate(get, set, (b) => ({
- ...b,
- updatedAt: ts,
- cards: { ...b.cards, [newId]: clone },
- columns: b.columns.map((c) =>
- c.id === column.id
- ? {
- ...c,
- cardIds: [
- ...c.cardIds.slice(0, insertIndex),
- newId,
- ...c.cardIds.slice(insertIndex),
- ],
- }
- : c
- ),
- }));
-
- return newId;
-},
-```
-
-**Step 2: Wrap CardThumbnail in ContextMenu**
-
-In `src/components/board/CardThumbnail.tsx`, add imports:
-
-```typescript
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuSub,
- ContextMenuSubContent,
- ContextMenuSubTrigger,
-} from "@/components/ui/context-menu";
-import { useBoardStore } from "@/stores/board-store";
-import type { Priority } from "@/types/board";
-```
-
-Wrap the `motion.button` in a `ContextMenu`. The return for the non-dragging case becomes:
-
-```typescript
-return (
-
-
- {/* existing content */}
-
-
-
-);
-```
-
-Add a `CardContextMenuContent` component (can be inline in the same file):
-
-```typescript
-function CardContextMenuContent({ cardId, columnId }: { cardId: string; columnId: string }) {
- const board = useBoardStore((s) => s.board);
- const moveCard = useBoardStore((s) => s.moveCard);
- const updateCard = useBoardStore((s) => s.updateCard);
- const duplicateCard = useBoardStore((s) => s.duplicateCard);
- const deleteCard = useBoardStore((s) => s.deleteCard);
-
- if (!board) return null;
-
- const otherColumns = board.columns.filter((c) => c.id !== columnId);
- const priorities: { value: Priority; label: string }[] = [
- { value: "none", label: "None" },
- { value: "low", label: "Low" },
- { value: "medium", label: "Medium" },
- { value: "high", label: "High" },
- { value: "urgent", label: "Urgent" },
- ];
-
- return (
-
- {otherColumns.length > 0 && (
-
- Move to
-
- {otherColumns.map((col) => (
- moveCard(cardId, columnId, col.id, col.cardIds.length)}
- >
- {col.title}
-
- ))}
-
-
- )}
-
- Set priority
-
- {priorities.map(({ value, label }) => (
- updateCard(cardId, { priority: value })}
- >
- {label}
-
- ))}
-
-
- duplicateCard(cardId)}>
- Duplicate
-
-
- deleteCard(cardId)}
- >
- Delete
-
-
- );
-}
-```
-
-Also add `import { ContextMenuTrigger } from "@/components/ui/context-menu";` to the imports if not already there.
-
-**Step 3: Verify**
-
-Run `npm run tauri dev`. Right-click a card. Context menu should show: Move to (submenu), Set priority (submenu), Duplicate, Delete.
-
-**Step 4: Commit**
-
-```
-git add src/components/board/CardThumbnail.tsx src/stores/board-store.ts
-git commit -m "feat: card context menu with move, priority, duplicate, delete"
-```
-
----
-
-### Task 11: #10 — WIP limits
-
-**Files:**
-- Modify: `src/stores/board-store.ts`
-- Modify: `src/components/board/ColumnHeader.tsx`
-- Modify: `src/components/board/KanbanColumn.tsx`
-
-**Step 1: Add setColumnWipLimit store action**
-
-In `src/stores/board-store.ts`, add to `BoardActions`:
-
-```typescript
-setColumnWipLimit: (columnId: string, limit: number | null) => void;
-```
-
-Add implementation after `setColumnColor`:
-
-```typescript
-setColumnWipLimit: (columnId, limit) => {
- mutate(get, set, (b) => ({
- ...b,
- updatedAt: now(),
- columns: b.columns.map((c) =>
- c.id === columnId ? { ...c, wipLimit: limit } : c
- ),
- }));
-},
-```
-
-**Step 2: Update ColumnHeader with WIP limit menu item**
-
-In `src/components/board/ColumnHeader.tsx`, add the store action:
-
-```typescript
-const setColumnWipLimit = useBoardStore((s) => s.setColumnWipLimit);
-```
-
-Add a WIP Limit submenu in the dropdown, after the Color submenu:
-
-```typescript
-
- WIP Limit
-
- setColumnWipLimit(column.id, v === "none" ? null : parseInt(v))}
- >
- None
- 3
- 5
- 7
- 10
-
-
-
-```
-
-**Step 3: Update ColumnHeader card count display**
-
-Replace the card count `` to show WIP status:
-
-```typescript
- column.wipLimit
- ? "text-pylon-danger font-bold"
- : column.wipLimit != null && cardCount === column.wipLimit
- ? "text-[oklch(65%_0.15_70)]"
- : "text-pylon-text-secondary"
-}`}>
- {cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""}
-
-```
-
-**Step 4: Update KanbanColumn with WIP tint**
-
-In `src/components/board/KanbanColumn.tsx`, compute a background tint based on WIP limit status. Add after `const cardCount`:
-
-```typescript
-const wipTint = column.wipLimit != null
- ? cardCount > column.wipLimit
- ? "oklch(70% 0.08 25 / 15%)" // red tint - over limit
- : cardCount === column.wipLimit
- ? "oklch(75% 0.08 70 / 15%)" // amber tint - at limit
- : undefined
- : undefined;
-```
-
-Apply it to the `motion.section` as an additional background style. Update the `style` prop on the `motion.section`:
-
-```typescript
-style={{ borderTop, backgroundColor: wipTint }}
-```
-
-**Step 5: Verify**
-
-Run `npm run tauri dev`. Set a WIP limit on a column. Add cards to exceed it. The header count should change color and the column should get a tinted background.
-
-**Step 6: Commit**
-
-```
-git add src/stores/board-store.ts src/components/board/ColumnHeader.tsx src/components/board/KanbanColumn.tsx
-git commit -m "feat: WIP limits with visual indicators"
-```
-
----
-
-### Task 12: #3 — Column collapse/expand
-
-**Files:**
-- Modify: `src/stores/board-store.ts`
-- Modify: `src/components/board/ColumnHeader.tsx`
-- Modify: `src/components/board/KanbanColumn.tsx`
-
-**Step 1: Add toggleColumnCollapse store action**
-
-In `src/stores/board-store.ts`, add to `BoardActions`:
-
-```typescript
-toggleColumnCollapse: (columnId: string) => void;
-```
-
-Add implementation:
-
-```typescript
-toggleColumnCollapse: (columnId) => {
- mutate(get, set, (b) => ({
- ...b,
- updatedAt: now(),
- columns: b.columns.map((c) =>
- c.id === columnId ? { ...c, collapsed: !c.collapsed } : c
- ),
- }));
-},
-```
-
-**Step 2: Add Collapse menu item to ColumnHeader**
-
-In `src/components/board/ColumnHeader.tsx`, add the store action:
-
-```typescript
-const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
-```
-
-Add menu item in the dropdown, after the Rename item:
-
-```typescript
- toggleColumnCollapse(column.id)}>
- Collapse
-
-```
-
-**Step 3: Render collapsed state in KanbanColumn**
-
-In `src/components/board/KanbanColumn.tsx`, add the store action:
-
-```typescript
-const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
-```
-
-Add import for `ChevronRight` from lucide-react.
-
-Inside the outer `motion.div`, before the `motion.section`, add a collapsed view. The logic: if `column.collapsed`, render the narrow strip instead of the full column. Update the `animate` on the outer `motion.div`:
-
-```typescript
-animate={{ width: column.collapsed ? 40 : width, opacity: 1 }}
-```
-
-Then wrap the `motion.section` in a conditional. If collapsed, show:
-
-```typescript
-{column.collapsed ? (
- toggleColumnCollapse(column.id)}
- className="flex h-full w-full flex-col items-center gap-2 rounded-lg bg-pylon-column py-3 hover:bg-pylon-column/80 transition-colors"
- style={{ borderTop }}
- >
-
-
- {column.title}
-
-
- {cardCount}
-
-
-) : (
- {/* existing full column content */}
-)}
-```
-
-**Step 4: Verify**
-
-Run `npm run tauri dev`. Use the column header dropdown to collapse a column. It should shrink to a 40px strip with vertical text. Click the strip to expand.
-
-**Step 5: Commit**
-
-```
-git add src/stores/board-store.ts src/components/board/ColumnHeader.tsx src/components/board/KanbanColumn.tsx
-git commit -m "feat: column collapse/expand with animated transition"
-```
-
----
-
-### Task 13: #11 — Checklist item reordering
-
-**Files:**
-- Modify: `src/stores/board-store.ts`
-- Modify: `src/components/card-detail/ChecklistSection.tsx`
-
-**Step 1: Add reorderChecklistItems store action**
-
-In `src/stores/board-store.ts`, add to `BoardActions`:
-
-```typescript
-reorderChecklistItems: (cardId: string, fromIndex: number, toIndex: number) => void;
-```
-
-Add implementation:
-
-```typescript
-reorderChecklistItems: (cardId, fromIndex, toIndex) => {
- mutate(get, set, (b) => {
- const card = b.cards[cardId];
- if (!card) return b;
- const items = [...card.checklist];
- const [moved] = items.splice(fromIndex, 1);
- items.splice(toIndex, 0, moved);
- return {
- ...b,
- updatedAt: now(),
- cards: {
- ...b.cards,
- [cardId]: { ...card, checklist: items, updatedAt: now() },
- },
- };
- });
-},
-```
-
-**Step 2: Add dnd-kit to ChecklistSection**
-
-In `src/components/card-detail/ChecklistSection.tsx`, add imports:
-
-```typescript
-import {
- DndContext,
- closestCenter,
- PointerSensor,
- useSensor,
- useSensors,
- type DragEndEvent,
-} from "@dnd-kit/core";
-import {
- SortableContext,
- verticalListSortingStrategy,
- useSortable,
-} from "@dnd-kit/sortable";
-import { CSS } from "@dnd-kit/utilities";
-import { GripVertical, X } from "lucide-react";
-```
-
-Add store action:
-
-```typescript
-const reorderChecklistItems = useBoardStore((s) => s.reorderChecklistItems);
-```
-
-Add sensors:
-
-```typescript
-const sensors = useSensors(
- useSensor(PointerSensor, { activationConstraint: { distance: 3 } })
-);
-```
-
-Add drag handler:
-
-```typescript
-function handleDragEnd(event: DragEndEvent) {
- const { active, over } = event;
- if (!over || active.id === over.id) return;
- const oldIndex = checklist.findIndex((item) => item.id === active.id);
- const newIndex = checklist.findIndex((item) => item.id === over.id);
- if (oldIndex !== -1 && newIndex !== -1) {
- reorderChecklistItems(cardId, oldIndex, newIndex);
- }
-}
-```
-
-Wrap the checklist items `div` in DndContext + SortableContext:
-
-```typescript
-
- item.id)} strategy={verticalListSortingStrategy}>
-
- {checklist.map((item) => (
-
- ))}
-
-
-
-```
-
-**Step 3: Make ChecklistRow sortable**
-
-Update `ChecklistRow` to use `useSortable`:
-
-```typescript
-function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps) {
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
- id: item.id,
- });
- // ...existing state...
-
- return (
-
-
-
-
- {/* rest of existing content */}
-
- );
-}
-```
-
-**Step 4: Verify**
-
-Run `npm run tauri dev`. Open a card with checklist items. Drag items by the grip handle to reorder them.
-
-**Step 5: Commit**
-
-```
-git add src/stores/board-store.ts src/components/card-detail/ChecklistSection.tsx
-git commit -m "feat: drag-and-drop checklist item reordering"
-```
-
----
-
-## Phase 3: Navigation & Power User Features
-
----
-
-### Task 14: #1 — Card filtering & quick search
-
-**Files:**
-- Create: `src/components/board/FilterBar.tsx`
-- Modify: `src/components/board/BoardView.tsx`
-- Modify: `src/components/board/KanbanColumn.tsx`
-- Modify: `src/components/layout/TopBar.tsx`
-
-**Step 1: Create FilterBar component**
-
-Create `src/components/board/FilterBar.tsx`:
-
-```typescript
-import { useState, useCallback, useEffect, useRef } from "react";
-import { motion } from "framer-motion";
-import { springs } from "@/lib/motion";
-import { X, Search } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import type { Label, Priority } from "@/types/board";
-
-export interface FilterState {
- text: string;
- labels: string[];
- dueDate: "all" | "overdue" | "week" | "today" | "none";
- priority: "all" | Priority;
-}
-
-export const EMPTY_FILTER: FilterState = {
- text: "",
- labels: [],
- dueDate: "all",
- priority: "all",
-};
-
-export function isFilterActive(f: FilterState): boolean {
- return f.text !== "" || f.labels.length > 0 || f.dueDate !== "all" || f.priority !== "all";
-}
-
-interface FilterBarProps {
- filters: FilterState;
- onChange: (filters: FilterState) => void;
- onClose: () => void;
- boardLabels: Label[];
-}
-
-export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBarProps) {
- const inputRef = useRef(null);
- const debounceRef = useRef | null>(null);
- const [textDraft, setTextDraft] = useState(filters.text);
-
- useEffect(() => {
- inputRef.current?.focus();
- }, []);
-
- const handleTextChange = useCallback(
- (value: string) => {
- setTextDraft(value);
- if (debounceRef.current) clearTimeout(debounceRef.current);
- debounceRef.current = setTimeout(() => {
- onChange({ ...filters, text: value });
- }, 200);
- },
- [filters, onChange]
- );
-
- function toggleLabel(labelId: string) {
- const labels = filters.labels.includes(labelId)
- ? filters.labels.filter((l) => l !== labelId)
- : [...filters.labels, labelId];
- onChange({ ...filters, labels });
- }
-
- function clearAll() {
- setTextDraft("");
- onChange(EMPTY_FILTER);
- }
-
- return (
-
-
- {/* Text search */}
-
-
- handleTextChange(e.target.value)}
- placeholder="Search cards..."
- className="h-5 w-40 bg-transparent text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
- />
-
-
- {/* Label filter chips */}
- {boardLabels.length > 0 && (
-
- {boardLabels.map((label) => (
- toggleLabel(label.id)}
- className={`rounded-full px-2 py-0.5 text-xs transition-all ${
- filters.labels.includes(label.id)
- ? "text-white"
- : "opacity-40 hover:opacity-70"
- }`}
- style={{ backgroundColor: label.color }}
- >
- {label.name}
-
- ))}
-
- )}
-
- {/* Due date filter */}
-
onChange({ ...filters, dueDate: e.target.value as FilterState["dueDate"] })}
- className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
- >
- All dates
- Overdue
- Due this week
- Due today
- No date
-
-
- {/* Priority filter */}
-
onChange({ ...filters, priority: e.target.value as FilterState["priority"] })}
- className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
- >
- All priorities
- Urgent
- High
- Medium
- Low
- No priority
-
-
- {/* Spacer + clear + close */}
-
- {isFilterActive(filters) && (
-
- Clear all
-
- )}
-
-
-
-
-
- );
-}
-```
-
-**Step 2: Add filter logic to BoardView**
-
-In `src/components/board/BoardView.tsx`, import and wire the filter bar:
-
-```typescript
-import { AnimatePresence } from "framer-motion";
-import { FilterBar, type FilterState, EMPTY_FILTER, isFilterActive } from "@/components/board/FilterBar";
-```
-
-Add filter state:
-
-```typescript
-const [showFilterBar, setShowFilterBar] = useState(false);
-const [filters, setFilters] = useState(EMPTY_FILTER);
-```
-
-Add `filterCards` helper inside the component:
-
-```typescript
-function filterCards(cardIds: string[]): string[] {
- if (!isFilterActive(filters) || !board) return cardIds;
- return cardIds.filter((id) => {
- const card = board.cards[id];
- if (!card) return false;
- if (filters.text && !card.title.toLowerCase().includes(filters.text.toLowerCase())) return false;
- if (filters.labels.length > 0 && !filters.labels.some((l) => card.labels.includes(l))) return false;
- if (filters.priority !== "all" && card.priority !== filters.priority) return false;
- if (filters.dueDate !== "all") {
- const now = new Date();
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- if (filters.dueDate === "none" && card.dueDate != null) return false;
- if (filters.dueDate === "overdue" && (!card.dueDate || new Date(card.dueDate) >= today)) return false;
- if (filters.dueDate === "today") {
- if (!card.dueDate) return false;
- const d = new Date(card.dueDate);
- if (d.toDateString() !== today.toDateString()) return false;
- }
- if (filters.dueDate === "week") {
- if (!card.dueDate) return false;
- const d = new Date(card.dueDate);
- const weekEnd = new Date(today);
- weekEnd.setDate(weekEnd.getDate() + 7);
- if (d < today || d > weekEnd) return false;
- }
- }
- return true;
- });
-}
-```
-
-Add keyboard shortcut for `/`:
-
-```typescript
-useEffect(() => {
- function handleKey(e: KeyboardEvent) {
- if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
- const tag = (e.target as HTMLElement).tagName;
- if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
- e.preventDefault();
- setShowFilterBar(true);
- }
- }
- document.addEventListener("keydown", handleKey);
- return () => document.removeEventListener("keydown", handleKey);
-}, []);
-```
-
-Render FilterBar above the DndContext, inside the main fragment:
-
-```typescript
-
- {showFilterBar && board && (
- { setShowFilterBar(false); setFilters(EMPTY_FILTER); }}
- boardLabels={board.labels}
- />
- )}
-
-```
-
-Pass `filteredCardIds` to `KanbanColumn`:
-
-```typescript
-
-```
-
-**Step 3: Update KanbanColumn to accept filteredCardIds**
-
-In `src/components/board/KanbanColumn.tsx`, add `filteredCardIds` prop:
-
-```typescript
-interface KanbanColumnProps {
- column: Column;
- filteredCardIds?: string[];
- onCardClick?: (cardId: string) => void;
- isNew?: boolean;
-}
-```
-
-Use it when rendering cards:
-
-```typescript
-const displayCardIds = filteredCardIds ?? column.cardIds;
-const isFiltering = filteredCardIds != null;
-```
-
-Update the card count display and the card rendering to use `displayCardIds`. If filtering, show `"3 of 7"` style count in the column header area.
-
-**Step 4: Add filter button to TopBar**
-
-In `src/components/layout/TopBar.tsx`, add a Filter button next to the board settings button. Import `Filter` from lucide-react.
-
-Add button that dispatches a custom event:
-
-```typescript
-
-
- document.dispatchEvent(new CustomEvent("toggle-filter-bar"))}
- >
-
-
-
-
- Filter cards /
-
-
-```
-
-In BoardView, listen for this event:
-
-```typescript
-useEffect(() => {
- function handleToggleFilter() {
- setShowFilterBar((prev) => !prev);
- }
- document.addEventListener("toggle-filter-bar", handleToggleFilter);
- return () => document.removeEventListener("toggle-filter-bar", handleToggleFilter);
-}, []);
-```
-
-**Step 5: Verify**
-
-Run `npm run tauri dev`. Press `/` or click the filter button. The filter bar should slide down. Type to search, click labels to filter, use dropdowns. Cards should filter in real-time.
-
-**Step 6: Commit**
-
-```
-git add src/components/board/FilterBar.tsx src/components/board/BoardView.tsx src/components/board/KanbanColumn.tsx src/components/layout/TopBar.tsx
-git commit -m "feat: card filtering and quick search with filter bar"
-```
-
----
-
-### Task 15: #7 — Keyboard card navigation
-
-**Files:**
-- Create: `src/hooks/useKeyboardNavigation.ts`
-- Modify: `src/components/board/BoardView.tsx`
-- Modify: `src/components/board/KanbanColumn.tsx`
-- Modify: `src/components/board/CardThumbnail.tsx`
-
-**Step 1: Create useKeyboardNavigation hook**
-
-Create `src/hooks/useKeyboardNavigation.ts`:
-
-```typescript
-import { useState, useEffect, useCallback } from "react";
-import type { Board } from "@/types/board";
-
-export function useKeyboardNavigation(
- board: Board | null,
- onOpenCard: (cardId: string) => void
-) {
- const [focusedCardId, setFocusedCardId] = useState(null);
-
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- if (!board) return;
- const tag = (e.target as HTMLElement).tagName;
- if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
-
- const key = e.key.toLowerCase();
- const isNav = ["j", "k", "h", "l", "arrowdown", "arrowup", "arrowleft", "arrowright", "enter", "escape"].includes(key);
- if (!isNav) return;
-
- e.preventDefault();
-
- if (key === "escape") {
- setFocusedCardId(null);
- return;
- }
-
- if (key === "enter" && focusedCardId) {
- onOpenCard(focusedCardId);
- return;
- }
-
- // Build navigation grid
- const columns = board.columns.filter((c) => !c.collapsed && c.cardIds.length > 0);
- if (columns.length === 0) return;
-
- // Find current position
- let colIdx = -1;
- let cardIdx = -1;
- if (focusedCardId) {
- for (let ci = 0; ci < columns.length; ci++) {
- const idx = columns[ci].cardIds.indexOf(focusedCardId);
- if (idx !== -1) {
- colIdx = ci;
- cardIdx = idx;
- break;
- }
- }
- }
-
- // If nothing focused, focus first card
- if (colIdx === -1) {
- setFocusedCardId(columns[0].cardIds[0]);
- return;
- }
-
- if (key === "j" || key === "arrowdown") {
- const col = columns[colIdx];
- const next = Math.min(cardIdx + 1, col.cardIds.length - 1);
- setFocusedCardId(col.cardIds[next]);
- } else if (key === "k" || key === "arrowup") {
- const col = columns[colIdx];
- const next = Math.max(cardIdx - 1, 0);
- setFocusedCardId(col.cardIds[next]);
- } else if (key === "l" || key === "arrowright") {
- const nextCol = Math.min(colIdx + 1, columns.length - 1);
- const targetIdx = Math.min(cardIdx, columns[nextCol].cardIds.length - 1);
- setFocusedCardId(columns[nextCol].cardIds[targetIdx]);
- } else if (key === "h" || key === "arrowleft") {
- const prevCol = Math.max(colIdx - 1, 0);
- const targetIdx = Math.min(cardIdx, columns[prevCol].cardIds.length - 1);
- setFocusedCardId(columns[prevCol].cardIds[targetIdx]);
- }
- },
- [board, focusedCardId, onOpenCard]
- );
-
- useEffect(() => {
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }, [handleKeyDown]);
-
- // Clear focus when a card is removed
- useEffect(() => {
- if (focusedCardId && board && !board.cards[focusedCardId]) {
- setFocusedCardId(null);
- }
- }, [board, focusedCardId]);
-
- return { focusedCardId, setFocusedCardId };
-}
-```
-
-**Step 2: Wire hook into BoardView**
-
-In `src/components/board/BoardView.tsx`:
-
-```typescript
-import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
-
-// Inside BoardView:
-const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, setSelectedCardId);
-```
-
-Pass `focusedCardId` to `KanbanColumn`:
-
-```typescript
-
-```
-
-Clear focus when opening a card (in `setSelectedCardId`):
-
-```typescript
-function handleCardClick(cardId: string) {
- setSelectedCardId(cardId);
- setFocusedCardId(null);
-}
-```
-
-**Step 3: Pass isFocused through KanbanColumn to CardThumbnail**
-
-In `src/components/board/KanbanColumn.tsx`, add `focusedCardId` prop and pass it through:
-
-```typescript
-interface KanbanColumnProps {
- column: Column;
- filteredCardIds?: string[];
- focusedCardId?: string | null;
- onCardClick?: (cardId: string) => void;
- isNew?: boolean;
-}
-```
-
-In the card render:
-
-```typescript
-
-```
-
-**Step 4: Add focus ring to CardThumbnail**
-
-In `src/components/board/CardThumbnail.tsx`, add `isFocused` prop:
-
-```typescript
-interface CardThumbnailProps {
- card: Card;
- boardLabels: Label[];
- columnId: string;
- onCardClick?: (cardId: string) => void;
- isFocused?: boolean;
-}
-```
-
-Add auto-scroll ref and effect:
-
-```typescript
-const cardRef = useRef(null);
-
-useEffect(() => {
- if (isFocused && cardRef.current) {
- cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
- }
-}, [isFocused]);
-```
-
-Add focus ring class to the `motion.button`:
-
-```typescript
-className={`w-full rounded-lg bg-pylon-surface shadow-sm text-left ${
- isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : ""
-}`}
-```
-
-Assign the ref (note: need to merge with sortable ref — use `useCallback` ref or pass to both):
-
-```typescript
-ref={(node) => {
- setNodeRef(node);
- (cardRef as React.MutableRefObject).current = node;
-}}
-```
-
-**Step 5: Verify**
-
-Run `npm run tauri dev`. Press `J`/`K` to navigate cards vertically, `H`/`L` for columns. Focused card should have an accent ring. `Enter` opens the card, `Escape` clears focus.
-
-**Step 6: Commit**
-
-```
-git add src/hooks/useKeyboardNavigation.ts src/components/board/BoardView.tsx src/components/board/KanbanColumn.tsx src/components/board/CardThumbnail.tsx
-git commit -m "feat: keyboard card navigation with J/K/H/L and focus ring"
-```
-
----
-
-### Task 16: #6 — Desktop notifications for due dates
-
-**Files:**
-- Modify: `src-tauri/Cargo.toml`
-- Modify: `src-tauri/capabilities/default.json`
-- Modify: `src/stores/app-store.ts`
-- Modify: `src/types/settings.ts`
-- Modify: `src/lib/schemas.ts`
-
-**Step 1: Add tauri-plugin-notification**
-
-In `src-tauri/Cargo.toml`, add to `[dependencies]`:
-
-```toml
-tauri-plugin-notification = "2"
-```
-
-Register the plugin in `src-tauri/src/lib.rs` (find existing `.plugin()` calls and add):
-
-```rust
-.plugin(tauri_plugin_notification::init())
-```
-
-In `src-tauri/capabilities/default.json`, add to `permissions` array:
-
-```json
-"notification:default"
-```
-
-Install the npm package:
-
-```bash
-npm install @tauri-apps/plugin-notification
-```
-
-**Step 2: Add lastNotificationCheck to settings**
-
-In `src/types/settings.ts`, add to `AppSettings`:
-
-```typescript
-lastNotificationCheck: string | null;
-```
-
-In `src/lib/schemas.ts`, add to `appSettingsSchema`:
-
-```typescript
-lastNotificationCheck: z.string().nullable().default(null),
-```
-
-In `src/stores/app-store.ts`, add to default settings:
-
-```typescript
-lastNotificationCheck: null,
-```
-
-**Step 3: Add notification check to init**
-
-In `src/stores/app-store.ts`, import and add notification logic to `init`:
-
-```typescript
-import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
-```
-
-After `set({ settings, boards, initialized: true })` in `init`, add:
-
-```typescript
-// Due date notifications (once per hour)
-const lastCheck = settings.lastNotificationCheck;
-const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
-if (!lastCheck || lastCheck < hourAgo) {
- try {
- let granted = await isPermissionGranted();
- if (!granted) {
- const perm = await requestPermission();
- granted = perm === "granted";
- }
- if (granted) {
- // Scan all boards for due cards
- let dueToday = 0;
- let overdue = 0;
- const today = new Date();
- const todayStr = today.toDateString();
-
- for (const meta of boards) {
- try {
- const board = await loadBoard(meta.id);
- for (const card of Object.values(board.cards)) {
- if (!card.dueDate) continue;
- const due = new Date(card.dueDate);
- if (due.toDateString() === todayStr) dueToday++;
- else if (due < today) overdue++;
- }
- } catch { /* skip */ }
- }
-
- if (dueToday > 0) {
- sendNotification({ title: "OpenPylon", body: `You have ${dueToday} card${dueToday > 1 ? "s" : ""} due today` });
- }
- if (overdue > 0) {
- sendNotification({ title: "OpenPylon", body: `You have ${overdue} overdue card${overdue > 1 ? "s" : ""}` });
- }
- }
- updateAndSave(get, set, { lastNotificationCheck: new Date().toISOString() });
- } catch { /* notification plugin not available */ }
-}
-```
-
-Note: `loadBoard` needs to be imported (it should already be accessible — check the import from storage).
-
-**Step 4: Verify**
-
-Run `npm run tauri dev` (requires `cargo build` for the new plugin). With cards that have due dates set to today or in the past, a system notification should appear.
-
-**Step 5: Commit**
-
-```
-git add src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/capabilities/default.json src/stores/app-store.ts src/types/settings.ts src/lib/schemas.ts package.json package-lock.json
-git commit -m "feat: desktop notifications for due/overdue cards"
-```
-
----
-
-### Task 17: #13 — Card comments / activity log
-
-**Files:**
-- Create: `src/components/card-detail/CommentsSection.tsx`
-- Modify: `src/stores/board-store.ts`
-- Modify: `src/components/card-detail/CardDetailModal.tsx`
-
-**Step 1: Add comment store actions**
-
-In `src/stores/board-store.ts`, add to `BoardActions`:
-
-```typescript
-addComment: (cardId: string, text: string) => void;
-deleteComment: (cardId: string, commentId: string) => void;
-```
-
-Add implementations:
-
-```typescript
-addComment: (cardId, text) => {
- mutate(get, set, (b) => {
- const card = b.cards[cardId];
- if (!card) return b;
- const comment = { id: ulid(), text, createdAt: now() };
- return {
- ...b,
- updatedAt: now(),
- cards: {
- ...b.cards,
- [cardId]: {
- ...card,
- comments: [comment, ...card.comments],
- updatedAt: now(),
- },
- },
- };
- });
-},
-
-deleteComment: (cardId, commentId) => {
- mutate(get, set, (b) => {
- const card = b.cards[cardId];
- if (!card) return b;
- return {
- ...b,
- updatedAt: now(),
- cards: {
- ...b.cards,
- [cardId]: {
- ...card,
- comments: card.comments.filter((c) => c.id !== commentId),
- updatedAt: now(),
- },
- },
- };
- });
-},
-```
-
-**Step 2: Create CommentsSection component**
-
-Create `src/components/card-detail/CommentsSection.tsx`:
-
-```typescript
-import { useState, useRef } from "react";
-import { formatDistanceToNow } from "date-fns";
-import { X } from "lucide-react";
-import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
-import { Button } from "@/components/ui/button";
-import { useBoardStore } from "@/stores/board-store";
-import type { Comment } from "@/types/board";
-
-interface CommentsSectionProps {
- cardId: string;
- comments: Comment[];
-}
-
-export function CommentsSection({ cardId, comments }: CommentsSectionProps) {
- const addComment = useBoardStore((s) => s.addComment);
- const deleteComment = useBoardStore((s) => s.deleteComment);
- const [draft, setDraft] = useState("");
- const textareaRef = useRef(null);
-
- function handleAdd() {
- const trimmed = draft.trim();
- if (!trimmed) return;
- addComment(cardId, trimmed);
- setDraft("");
- textareaRef.current?.focus();
- }
-
- function handleKeyDown(e: React.KeyboardEvent) {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleAdd();
- }
- }
-
- return (
-
-
- Comments
-
-
- {/* Add comment */}
-
-
-
- {/* Comment list */}
- {comments.length > 0 && (
-
-
- {comments.map((comment) => (
-
-
-
- {comment.text}
-
-
- {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
-
-
-
deleteComment(cardId, comment.id)}
- className="shrink-0 self-start rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/comment:opacity-100"
- aria-label="Delete comment"
- >
-
-
-
- ))}
-
-
- )}
-
- );
-}
-```
-
-**Step 3: Add CommentsSection to CardDetailModal**
-
-In `src/components/card-detail/CardDetailModal.tsx`, import:
-
-```typescript
-import { CommentsSection } from "@/components/card-detail/CommentsSection";
-```
-
-Add a new full-width row at the bottom of the grid (after attachments):
-
-```typescript
-{/* Row 5: Comments (full width) */}
-
-
-
-```
-
-**Step 4: Verify**
-
-Run `npm run tauri dev`. Open a card. Comments section should appear at the bottom. Add a comment with Enter, see it appear with relative timestamp. Delete with X on hover.
-
-**Step 5: Commit**
-
-```
-git add src/components/card-detail/CommentsSection.tsx src/stores/board-store.ts src/components/card-detail/CardDetailModal.tsx
-git commit -m "feat: card comments with add/delete and timestamps"
-```
-
----
-
-## Phase 4: System Features & Infrastructure
-
----
-
-### Task 18: #14 — Board templates & saved structures
-
-**Files:**
-- Create: `src/types/template.ts`
-- Modify: `src/lib/storage.ts`
-- Modify: `src/lib/board-factory.ts`
-- Modify: `src/components/boards/BoardCard.tsx` (add "Save as Template" menu item)
-- Modify: `src/components/boards/BoardList.tsx` (update NewBoardDialog to show templates)
-
-**Step 1: Create template type**
-
-Create `src/types/template.ts`:
-
-```typescript
-import type { ColumnWidth, Label, BoardSettings } from "./board";
-
-export interface BoardTemplate {
- id: string;
- name: string;
- color: string;
- columns: {
- title: string;
- width: ColumnWidth;
- color: string | null;
- wipLimit: number | null;
- }[];
- labels: Label[];
- settings: BoardSettings;
-}
-```
-
-**Step 2: Add template storage functions**
-
-In `src/lib/storage.ts`, add a templates directory helper and CRUD:
-
-```typescript
-async function getTemplatesDir(): Promise {
- const base = await getBaseDir();
- return join(base, "templates");
-}
-```
-
-Update `ensureDataDirs` to create templates dir:
-
-```typescript
-const templatesDir = await getTemplatesDir();
-if (!(await exists(templatesDir))) {
- await mkdir(templatesDir, { recursive: true });
-}
-```
-
-Add template functions:
-
-```typescript
-export async function listTemplates(): Promise {
- const dir = await getTemplatesDir();
- if (!(await exists(dir))) return [];
- const entries = await readDir(dir);
- const templates: BoardTemplate[] = [];
- for (const entry of entries) {
- if (!entry.name || !entry.name.endsWith(".json")) continue;
- try {
- const filePath = await join(dir, entry.name);
- const raw = await readTextFile(filePath);
- templates.push(JSON.parse(raw));
- } catch { continue; }
- }
- return templates;
-}
-
-export async function saveTemplate(template: BoardTemplate): Promise {
- const dir = await getTemplatesDir();
- const filePath = await join(dir, `${template.id}.json`);
- await writeTextFile(filePath, JSON.stringify(template, null, 2));
-}
-
-export async function deleteTemplate(templateId: string): Promise {
- const dir = await getTemplatesDir();
- const filePath = await join(dir, `${templateId}.json`);
- if (await exists(filePath)) {
- await remove(filePath);
- }
-}
-```
-
-Import the type:
-
-```typescript
-import type { BoardTemplate } from "@/types/template";
-```
-
-**Step 3: Add createBoardFromTemplate to board-factory**
-
-In `src/lib/board-factory.ts`:
-
-```typescript
-import type { BoardTemplate } from "@/types/template";
-
-export function createBoardFromTemplate(template: BoardTemplate, title: string): Board {
- const ts = new Date().toISOString();
- return {
- id: ulid(),
- title,
- color: template.color,
- createdAt: ts,
- updatedAt: ts,
- columns: template.columns.map((c) => ({
- id: ulid(),
- title: c.title,
- cardIds: [],
- width: c.width,
- color: c.color,
- collapsed: false,
- wipLimit: c.wipLimit,
- })),
- cards: {},
- labels: template.labels.map((l) => ({ ...l, id: ulid() })),
- settings: { ...template.settings },
- };
-}
-```
-
-**Step 4: Add "Save as Template" to BoardCard context menu**
-
-In `src/components/boards/BoardCard.tsx`, add a template save handler:
-
-```typescript
-import { listTemplates, saveTemplate, loadBoard, saveBoard, deleteBoard } from "@/lib/storage";
-import type { BoardTemplate } from "@/types/template";
-
-async function handleSaveAsTemplate() {
- const full = await loadBoard(board.id);
- const { ulid } = await import("ulid");
- const template: BoardTemplate = {
- id: ulid(),
- name: full.title,
- color: full.color,
- columns: full.columns.map((c) => ({
- title: c.title,
- width: c.width,
- color: c.color,
- wipLimit: c.wipLimit,
- })),
- labels: full.labels,
- settings: full.settings,
- };
- await saveTemplate(template);
- addToast(`Template "${full.title}" saved`, "success");
-}
-```
-
-Add menu item in the context menu (after Duplicate):
-
-```typescript
-
-
- Save as Template
-
-```
-
-Import `Bookmark` from lucide-react.
-
-**Step 5: Update NewBoardDialog to show templates**
-
-The `NewBoardDialog` component needs to be found/updated to load templates from storage and show them as options alongside the built-in Blank/Kanban/Sprint templates. User templates get a delete button. When selected, use `createBoardFromTemplate` instead of `createBoard`.
-
-This involves finding the new board dialog (likely in `BoardList.tsx`) and adding template support. The exact implementation depends on the existing dialog structure — load templates with `useEffect` + `listTemplates()`, display as a grid of clickable options.
-
-**Step 6: Verify**
-
-Run `npm run tauri dev`. Right-click a board card > "Save as Template". Create a new board — the saved template should appear as an option.
-
-**Step 7: Commit**
-
-```
-git add src/types/template.ts src/lib/storage.ts src/lib/board-factory.ts src/components/boards/BoardCard.tsx src/components/boards/BoardList.tsx
-git commit -m "feat: board templates - save and create from templates"
-```
-
----
-
-### Task 19: #15 — Auto-backup & version history
-
-**Files:**
-- Modify: `src/lib/storage.ts`
-- Modify: `src/stores/board-store.ts`
-- Create: `src/components/board/VersionHistoryDialog.tsx`
-- Modify: `src/components/layout/TopBar.tsx`
-
-**Step 1: Add backup storage functions**
-
-In `src/lib/storage.ts`, add:
-
-```typescript
-async function getBackupsDir(boardId: string): Promise {
- const base = await getBaseDir();
- return join(base, "backups", boardId);
-}
-
-export interface BackupEntry {
- filename: string;
- timestamp: string;
- cardCount: number;
- columnCount: number;
-}
-
-export async function listBackups(boardId: string): Promise {
- const dir = await getBackupsDir(boardId);
- if (!(await exists(dir))) return [];
- const entries = await readDir(dir);
- const backups: BackupEntry[] = [];
- for (const entry of entries) {
- if (!entry.name || !entry.name.endsWith(".json")) continue;
- try {
- const filePath = await join(dir, entry.name);
- const raw = await readTextFile(filePath);
- const data = JSON.parse(raw);
- const board = boardSchema.parse(data);
- // Extract timestamp from filename: {boardId}-{ISO}.json
- const isoMatch = entry.name.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z/);
- backups.push({
- filename: entry.name,
- timestamp: isoMatch ? isoMatch[0] : board.updatedAt,
- cardCount: Object.keys(board.cards).length,
- columnCount: board.columns.length,
- });
- } catch { continue; }
- }
- backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
- return backups;
-}
-
-export async function createBackup(board: Board): Promise {
- const dir = await getBackupsDir(board.id);
- if (!(await exists(dir))) {
- await mkdir(dir, { recursive: true });
- }
- const ts = new Date().toISOString().replace(/:/g, "-");
- const filename = `${board.id}-${ts}.json`;
- const filePath = await join(dir, filename);
- await writeTextFile(filePath, JSON.stringify(board, null, 2));
-}
-
-export async function pruneBackups(boardId: string, keep: number = 10): Promise {
- const backups = await listBackups(boardId);
- if (backups.length <= keep) return;
- const dir = await getBackupsDir(boardId);
- const toDelete = backups.slice(keep);
- for (const backup of toDelete) {
- try {
- const filePath = await join(dir, backup.filename);
- await remove(filePath);
- } catch { /* skip */ }
- }
-}
-
-export async function restoreBackupFile(boardId: string, filename: string): Promise {
- const dir = await getBackupsDir(boardId);
- const filePath = await join(dir, filename);
- const raw = await readTextFile(filePath);
- const data = JSON.parse(raw);
- const board = boardSchema.parse(data) as Board;
- return board;
-}
-```
-
-**Step 2: Integrate auto-backup into saveBoard**
-
-Update `saveBoard` in `src/lib/storage.ts` to create timestamped backups:
-
-```typescript
-export async function saveBoard(board: Board): Promise {
- const boardsDir = await getBoardsDir();
- const filePath = await boardFilePath(boardsDir, board.id);
- const backupPath = await boardBackupPath(boardsDir, board.id);
-
- // Rotate previous version to backup
- if (await exists(filePath)) {
- try {
- const previous = await readTextFile(filePath);
- await writeTextFile(backupPath, previous);
- // Also create a timestamped backup
- await createBackup(JSON.parse(previous) as Board);
- await pruneBackups(board.id);
- } catch {
- // If we can't create a backup, continue saving anyway
- }
- }
-
- await writeTextFile(filePath, JSON.stringify(board, null, 2));
-}
-```
-
-Note: This will create many backups since `saveBoard` is called on every debounced change. To avoid excessive backups, add a throttle — only create a timestamped backup if the last one is more than 5 minutes old:
-
-```typescript
-// Only create timestamped backup if last backup > 5 min ago
-const backups = await listBackups(board.id);
-const recentThreshold = new Date(Date.now() - 5 * 60 * 1000).toISOString();
-if (backups.length === 0 || backups[0].timestamp < recentThreshold) {
- await createBackup(JSON.parse(previous) as Board);
- await pruneBackups(board.id);
-}
-```
-
-**Step 3: Create VersionHistoryDialog**
-
-Create `src/components/board/VersionHistoryDialog.tsx`:
-
-```typescript
-import { useState, useEffect } from "react";
-import { formatDistanceToNow } from "date-fns";
-import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { listBackups, restoreBackupFile, saveBoard, type BackupEntry } from "@/lib/storage";
-import { useBoardStore } from "@/stores/board-store";
-
-interface VersionHistoryDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-export function VersionHistoryDialog({ open, onOpenChange }: VersionHistoryDialogProps) {
- const board = useBoardStore((s) => s.board);
- const [backups, setBackups] = useState([]);
- const [confirmRestore, setConfirmRestore] = useState(null);
-
- useEffect(() => {
- if (open && board) {
- listBackups(board.id).then(setBackups);
- }
- }, [open, board]);
-
- async function handleRestore(backup: BackupEntry) {
- if (!board) return;
- // Back up current state before restoring
- await saveBoard(board);
- const restored = await restoreBackupFile(board.id, backup.filename);
- await saveBoard(restored);
- // Reload
- await useBoardStore.getState().openBoard(board.id);
- setConfirmRestore(null);
- onOpenChange(false);
- }
-
- return (
- <>
-
-
-
-
- Version History
-
-
- Browse and restore previous versions of this board.
-
-
-
- {backups.length > 0 ? (
-
- {backups.map((backup) => (
-
-
-
- {formatDistanceToNow(new Date(backup.timestamp), { addSuffix: true })}
-
-
- {backup.cardCount} cards, {backup.columnCount} columns
-
-
-
setConfirmRestore(backup)}
- className="text-pylon-accent"
- >
- Restore
-
-
- ))}
-
- ) : (
-
- No backups yet. Backups are created automatically as you work.
-
- )}
-
-
-
-
- {/* Restore confirmation */}
- setConfirmRestore(null)}>
-
-
-
- Restore Version
-
-
- This will replace the current board with the selected version. Your current state will be backed up first.
-
-
-
- setConfirmRestore(null)} className="text-pylon-text-secondary">
- Cancel
-
- confirmRestore && handleRestore(confirmRestore)}>
- Restore
-
-
-
-
- >
- );
-}
-```
-
-**Step 4: Add Version History to TopBar**
-
-In `src/components/layout/TopBar.tsx`, add a "Version History" menu item to the board settings dropdown:
-
-Import and add state:
-
-```typescript
-import { VersionHistoryDialog } from "@/components/board/VersionHistoryDialog";
-```
-
-In TopBar component:
-
-```typescript
-const [showVersionHistory, setShowVersionHistory] = useState(false);
-```
-
-Add menu item inside the `DropdownMenuContent` for board settings (after the Attachments submenu):
-
-```typescript
-
- setShowVersionHistory(true)}>
- Version History
-
-```
-
-Import `DropdownMenuSeparator` and `DropdownMenuItem` (add to existing import).
-
-Render the dialog at the bottom of the component return:
-
-```typescript
-{isBoardView && (
-
-)}
-```
-
-**Step 5: Ensure backups directory is created**
-
-In `src/lib/storage.ts`, update `ensureDataDirs` to create the backups directory:
-
-```typescript
-const backupsDir = await join(base, "backups");
-if (!(await exists(backupsDir))) {
- await mkdir(backupsDir, { recursive: true });
-}
-```
-
-**Step 6: Verify**
-
-Run `npm run tauri dev`. Make changes to a board. Open board settings > Version History. Backups should be listed. Click Restore on one and confirm.
-
-**Step 7: Commit**
-
-```
-git add src/lib/storage.ts src/stores/board-store.ts src/components/board/VersionHistoryDialog.tsx src/components/layout/TopBar.tsx
-git commit -m "feat: auto-backup and version history with restore"
-```
-
----
-
-## Summary
-
-| Phase | Tasks | Features |
-|-------|-------|----------|
-| 0 | 1-3 | Comment type, Card priority+comments, Column collapsed+wipLimit |
-| 1 | 4-7 | defaultColumnWidth, due date colors, card aging, open attachments |
-| 2 | 8-13 | Priority UI, context menu, WIP limits, collapse columns, checklist reorder |
-| 3 | 14-17 | Filter bar, keyboard nav, notifications, comments |
-| 4 | 18-19 | Templates, auto-backup + version history |
-
-Each phase is independently shippable. The app remains functional after completing any phase.
diff --git a/index.html b/index.html
index c42fcd3..11af717 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,6 @@
-
OpenPylon
diff --git a/public/tauri.svg b/public/tauri.svg
deleted file mode 100644
index 31b62c9..0000000
--- a/public/tauri.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/public/vite.svg b/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file