From 6f0d8c5f28649253400a7b0a30d15556bcad588b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Feb 2026 19:15:55 +0200 Subject: [PATCH] clean up unused files and update gitignore --- .gitignore | 24 - .../2026-02-15-card-detail-redesign-design.md | 73 - ...-15-card-detail-redesign-implementation.md | 467 ---- .../2026-02-15-custom-date-picker-design.md | 87 - ...02-15-custom-date-picker-implementation.md | 456 --- ...6-02-15-motion-darkmode-titlebar-design.md | 188 -- ...motion-darkmode-titlebar-implementation.md | 1273 --------- .../2026-02-15-openpylon-implementation.md | 1891 ------------- .../2026-02-15-openpylon-kanban-design.md | 345 --- .../plans/2026-02-15-visual-glow-up-design.md | 235 -- ...026-02-15-visual-glow-up-implementation.md | 1766 ------------ .../2026-02-16-15-improvements-design.md | 296 -- ...26-02-16-15-improvements-implementation.md | 2474 ----------------- index.html | 1 - public/tauri.svg | 6 - public/vite.svg | 1 - src/assets/react.svg | 1 - 17 files changed, 9584 deletions(-) delete mode 100644 .gitignore delete mode 100644 docs/plans/2026-02-15-card-detail-redesign-design.md delete mode 100644 docs/plans/2026-02-15-card-detail-redesign-implementation.md delete mode 100644 docs/plans/2026-02-15-custom-date-picker-design.md delete mode 100644 docs/plans/2026-02-15-custom-date-picker-implementation.md delete mode 100644 docs/plans/2026-02-15-motion-darkmode-titlebar-design.md delete mode 100644 docs/plans/2026-02-15-motion-darkmode-titlebar-implementation.md delete mode 100644 docs/plans/2026-02-15-openpylon-implementation.md delete mode 100644 docs/plans/2026-02-15-openpylon-kanban-design.md delete mode 100644 docs/plans/2026-02-15-visual-glow-up-design.md delete mode 100644 docs/plans/2026-02-15-visual-glow-up-implementation.md delete mode 100644 docs/plans/2026-02-16-15-improvements-design.md delete mode 100644 docs/plans/2026-02-16-15-improvements-implementation.md delete mode 100644 public/tauri.svg delete mode 100644 public/vite.svg delete mode 100644 src/assets/react.svg diff --git a/.gitignore b/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/docs/plans/2026-02-15-card-detail-redesign-design.md b/docs/plans/2026-02-15-card-detail-redesign-design.md deleted file mode 100644 index f202b7b..0000000 --- a/docs/plans/2026-02-15-card-detail-redesign-design.md +++ /dev/null @@ -1,73 +0,0 @@ -# Card Detail Modal Redesign — Design Document - -**Date:** 2026-02-15 -**Status:** Approved - -## Problem - -The current card detail modal uses a 60/40 split layout with a massive markdown editor on the left and all metadata (cover, labels, due date, checklist, attachments) crammed into a narrow right sidebar. This is unbalanced — the description field dominates despite being lightly used, while actionable sections like checklist are squeezed. - -## Design Decisions (from brainstorming) - -| Question | Answer | -|----------|--------| -| Primary use when opening card | Scan everything at once | -| Description importance | Light use (short notes) | -| Modal size | Go wider | -| Layout style | Dashboard grid | -| Section prominence | Equal weight | -| Long checklist behavior | Scroll within cell | -| Title position | Full-width header with cover color | -| Attachment display | Compact list | -| Description visibility | Always visible cell in grid | - -## Layout - -``` -┌──────────────────────────────────────────────────────────┐ -│ ██████████████████████████████████████████████████ [×] │ Cover color header -│ Card Title (click to edit) │ Inline-editable -├────────────────────────────┬─────────────────────────────┤ -│ LABELS │ DUE DATE │ Row 1: metadata -│ [Tag] [Tag] [Tag] [+] │ Feb 20 · 5 days left │ -├────────────────────────────┼─────────────────────────────┤ -│ CHECKLIST 2/5 │ DESCRIPTION │ Row 2: content -│ ✓ item 1 │ Short notes here... │ -│ ☐ item 2 (scroll) │ (click to edit) │ -│ + Add item │ │ -├────────────────────────────┼─────────────────────────────┤ -│ COVER │ ATTACHMENTS │ Row 3: secondary -│ ○ ○ ○ ○ ○ ○ ○ ○ × │ file.pdf · doc.png │ -│ │ [+ Add file] │ -└────────────────────────────┴─────────────────────────────┘ -``` - -### Header -- Full-width bar with cover color as background (or neutral `pylon-surface` if no cover) -- White text on colored bg, normal text on surface bg -- Inline-editable title (click → input → Enter/Escape) -- Close [×] button top-right - -### Grid Body -- CSS Grid: `grid-template-columns: 1fr 1fr`, `gap: 1rem` -- Each cell: `rounded-lg bg-pylon-column/50 p-4` -- Section headers: `font-mono text-xs uppercase tracking-widest text-pylon-text-secondary` -- Row 1: Labels + Due Date (small metadata) -- Row 2: Checklist + Description (main content, max-h ~200px with internal scroll) -- Row 3: Cover Color + Attachments (secondary) - -### Modal -- Width: `max-w-4xl` (up from `max-w-3xl`) -- Max height: `max-h-[85vh]` with body scrollable -- Shared layout animation preserved (`layoutId`) - -### Animation -- Grid cells stagger in with `fadeSlideUp` + `staggerContainer(0.05)` -- Backdrop blur + fade (existing) -- Escape/click-outside to close (existing) - -## Files to Modify -- `src/components/card-detail/CardDetailModal.tsx` — Full rewrite of layout -- `src/components/card-detail/MarkdownEditor.tsx` — Remove min-h-200, adapt for grid cell -- `src/components/card-detail/ChecklistSection.tsx` — Add max-height + scroll -- Minor: LabelPicker, DueDatePicker, AttachmentSection — No structural changes needed diff --git a/docs/plans/2026-02-15-card-detail-redesign-implementation.md b/docs/plans/2026-02-15-card-detail-redesign-implementation.md deleted file mode 100644 index 484e8a1..0000000 --- a/docs/plans/2026-02-15-card-detail-redesign-implementation.md +++ /dev/null @@ -1,467 +0,0 @@ -# Card Detail Modal Redesign — Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Replace the 60/40 split card detail modal with a 2-column dashboard grid where every section gets equal weight. - -**Architecture:** Full rewrite of `CardDetailModal.tsx` to use CSS Grid (2 cols, 3 rows) under a cover-color header. Sub-components (`MarkdownEditor`, `ChecklistSection`) get minor tweaks for cell sizing. `CoverColorPicker` moves from inline private component to its own grid cell. Framer Motion stagger preserved. - -**Tech Stack:** React 19, TypeScript, Framer Motion 12, Tailwind 4, Zustand - ---- - -### Task 1: Rewrite CardDetailModal — header + grid shell - -**Files:** -- Modify: `src/components/card-detail/CardDetailModal.tsx` (full rewrite, lines 1-245) - -**Step 1: Replace the entire file with the new layout** - -Replace the full contents of `CardDetailModal.tsx` with: - -```tsx -import { useState, useEffect, useRef } from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { X } from "lucide-react"; -import { useBoardStore } from "@/stores/board-store"; -import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor"; -import { ChecklistSection } from "@/components/card-detail/ChecklistSection"; -import { LabelPicker } from "@/components/card-detail/LabelPicker"; -import { DueDatePicker } from "@/components/card-detail/DueDatePicker"; -import { AttachmentSection } from "@/components/card-detail/AttachmentSection"; -import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion"; - -interface CardDetailModalProps { - cardId: string | null; - onClose: () => void; -} - -export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) { - const card = useBoardStore((s) => - cardId ? s.board?.cards[cardId] ?? null : null - ); - const boardLabels = useBoardStore((s) => s.board?.labels ?? []); - const updateCard = useBoardStore((s) => s.updateCard); - - const open = cardId != null && card != null; - - return ( - - {open && card && cardId && ( - <> - {/* Backdrop */} - - - {/* Modal */} -
- e.stopPropagation()} - > - - Card detail editor - - {/* Header: cover color background + title + close */} -
- - -
- - {/* Dashboard grid body */} - - {/* Row 1: Labels + Due Date */} - - - - - - - - - {/* Row 2: Checklist + Description */} - - - - - - - - - {/* Row 3: Cover + Attachments */} - - - - - - - - -
-
- - )} -
- ); -} - -/* ---------- Escape key handler ---------- */ - -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; -} - -/* ---------- Inline editable title ---------- */ - -interface InlineTitleProps { - cardId: string; - title: string; - updateCard: (cardId: string, updates: { title: string }) => void; - hasColor: boolean; -} - -function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(title); - const inputRef = useRef(null); - - useEffect(() => { - setDraft(title); - }, [title]); - - useEffect(() => { - if (editing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [editing]); - - function handleSave() { - const trimmed = draft.trim(); - if (trimmed && trimmed !== title) { - updateCard(cardId, { title: trimmed }); - } else { - setDraft(title); - } - setEditing(false); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter") { - e.preventDefault(); - handleSave(); - } else if (e.key === "Escape") { - setDraft(title); - setEditing(false); - } - } - - const textColor = hasColor ? "text-white" : "text-pylon-text"; - const hoverColor = hasColor ? "hover:text-white/80" : "hover:text-pylon-accent"; - - if (editing) { - return ( - setDraft(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyDown} - className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${ - hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent" - }`} - /> - ); - } - - return ( -

setEditing(true)} - className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`} - > - {title} -

- ); -} - -/* ---------- Cover color picker ---------- */ - -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 -

-
- - {presets.map(({ hue, label }) => ( -
-
- ); -} -``` - -**Step 2: Verify TypeScript compiles** - -Run: `npx tsc --noEmit` -Expected: No errors - -**Step 3: Commit** - -```bash -git add src/components/card-detail/CardDetailModal.tsx -git commit -m "feat: rewrite card detail modal as 2-column dashboard grid" -``` - ---- - -### Task 2: Adapt MarkdownEditor for grid cell - -**Files:** -- Modify: `src/components/card-detail/MarkdownEditor.tsx` (lines 93-103) - -**Step 1: Replace `min-h-[200px]` with cell-friendly sizing** - -In `MarkdownEditor.tsx`, change the textarea className (line 99): - -``` -Old: className="min-h-[200px] w-full resize-y rounded-md ... -New: className="min-h-[100px] max-h-[160px] w-full resize-y rounded-md ... -``` - -And change the preview container className (line 103): - -``` -Old: className="min-h-[200px] cursor-pointer rounded-md ... -New: className="min-h-[100px] max-h-[160px] overflow-y-auto cursor-pointer rounded-md ... -``` - -**Step 2: Verify TypeScript compiles** - -Run: `npx tsc --noEmit` -Expected: No errors - -**Step 3: Commit** - -```bash -git add src/components/card-detail/MarkdownEditor.tsx -git commit -m "feat: adapt markdown editor sizing for dashboard grid cell" -``` - ---- - -### Task 3: Add scroll containment to ChecklistSection - -**Files:** -- Modify: `src/components/card-detail/ChecklistSection.tsx` (lines 52-64) - -**Step 1: Add max-height + overflow to the checklist items container** - -In `ChecklistSection.tsx`, change the items container (line 53): - -``` -Old:
-New:
-``` - -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 */} -
- - -
- - -
- - -
- - {/* 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 ( - - ); - })} -
- - )} - - {viewMode === "months" && ( - - {MONTH_NAMES.map((name, i) => ( - - ))} - - )} - - {viewMode === "years" && ( - - {yearRange.map((year) => ( - - ))} - - )} - -
- - {/* Footer */} -
- - -
- - - ); -} -``` - -**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 */} - - - -
- ); -} -``` - -**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. -

-
-
- - -
-
-``` - -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 ` - )} - - {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 - )} - - - - - - Command palette - - - - - - - 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 ` - ))} -
      - - {/* Tab content */} -
      - {tab === "appearance" && ( - <> - {/* Theme */} -
      - Theme -
      - {THEME_OPTIONS.map(({ value, label, icon: Icon }) => ( - - ))} -
      -
      - - - - {/* UI Zoom */} -
      -
      - UI Zoom -
      - - {Math.round(settings.uiZoom * 100)}% - - {settings.uiZoom !== 1 && ( - - )} -
      -
      - 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 ( -
      -
      - - - - {/* Density */} -
      - Density -
      - {DENSITY_OPTIONS.map(({ value, label }) => ( - - ))} -
      -
      - - )} - - {tab === "boards" && ( -
      - Default Column Width -
      - {WIDTH_OPTIONS.map(({ value, 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 }) => ( -
        -
        -
        -``` - -**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 -

        -
        - - {presets.map(({ hue, 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 && ( - <> - - - - - - Undo Ctrl+Z - - - - - - - - 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. -

        -
        -
        - - -
        -
        - - - ); -} -``` - -**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 - -``` - -**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 }) => ( - - ))} -
          -
          - ); -} -``` - -**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 ? ( - -) : ( - {/* 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) => ( - - ))} -
          - )} - - {/* Due date filter */} - - - {/* Priority filter */} - - - {/* Spacer + clear + close */} -
          - {isFilterActive(filters) && ( - - )} - -
          - - ); -} -``` - -**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 - - - - - - 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 */} -
          -