Detailed step-by-step plan covering scaffold, types, storage, stores, UI components, drag-and-drop, command palette, import/export, animations, accessibility, and production build.
56 KiB
OpenPylon Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a local-first Kanban board desktop app using Tauri v2 + React + TypeScript that saves to local JSON files.
Architecture: Monolithic Zustand store per board, loaded from JSON on open, debounced writes on mutation. Tauri v2 handles filesystem access. React + shadcn/ui + Tailwind for UI, dnd-kit for drag-and-drop.
Tech Stack: Tauri v2, React 19, TypeScript, Zustand, zundo, dnd-kit, shadcn/ui, Tailwind CSS, Framer Motion, Zod, cmdk, ulid
Task 1: Scaffold Tauri + React Project
Files:
- Create: entire project scaffold via
create-tauri-app - Modify:
package.json(add dependencies) - Modify:
src-tauri/Cargo.toml(add plugins) - Modify:
src-tauri/capabilities/default.json(filesystem permissions)
Step 1: Create the Tauri app
Run:
cd D:/gdfhbfgdbnbdfbdf/openpylon
npm create tauri-app@latest . -- --template react-ts --manager npm
If the interactive prompt appears, select:
- Project name:
openpylon - Frontend: React
- Language: TypeScript
- Package manager: npm
Step 2: Install Tauri filesystem plugin
Run:
cd D:/gdfhbfgdbnbdfbdf/openpylon
npm install @tauri-apps/plugin-fs @tauri-apps/plugin-dialog @tauri-apps/plugin-shell
Add the plugin to src-tauri/Cargo.toml dependencies:
[dependencies]
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
Register plugins in src-tauri/src/lib.rs:
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Step 3: Configure filesystem permissions
Modify src-tauri/capabilities/default.json to include:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for OpenPylon",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"shell:default",
{
"identifier": "fs:default",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-exists",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-read",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-write",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-mkdir",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
}
]
}
Step 4: Install all frontend dependencies
Run:
npm install zustand zundo @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities framer-motion zod ulid date-fns react-markdown remark-gfm
Step 5: Install and init shadcn/ui + Tailwind
Run:
npx shadcn@latest init
Select: TypeScript, default style, base color neutral, CSS variables yes.
Then add required components:
npx shadcn@latest add dialog dropdown-menu tooltip command context-menu popover button input scroll-area badge separator textarea
Step 6: Verify build
Run:
npm run tauri dev
Expected: Tauri dev window opens with default React template.
Step 7: Commit
git add -A
git commit -m "feat: scaffold Tauri v2 + React + TS project with all dependencies"
Task 2: Type Definitions + Zod Schemas
Files:
- Create:
src/types/board.ts - Create:
src/types/settings.ts - Create:
src/lib/schemas.ts
Step 1: Create board type definitions
Create src/types/board.ts:
export interface Board {
id: string;
title: string;
color: string;
createdAt: string;
updatedAt: string;
columns: Column[];
cards: Record<string, Card>;
labels: Label[];
settings: BoardSettings;
}
export interface Column {
id: string;
title: string;
cardIds: string[];
width: ColumnWidth;
}
export type ColumnWidth = "narrow" | "standard" | "wide";
export interface Card {
id: string;
title: string;
description: string;
labels: string[];
checklist: ChecklistItem[];
dueDate: string | null;
attachments: Attachment[];
createdAt: string;
updatedAt: string;
}
export interface Label {
id: string;
name: string;
color: string;
}
export interface ChecklistItem {
id: string;
text: string;
checked: boolean;
}
export interface Attachment {
id: string;
name: string;
path: string;
mode: "link" | "copy";
}
export interface BoardSettings {
attachmentMode: "link" | "copy";
}
export interface BoardMeta {
id: string;
title: string;
color: string;
cardCount: number;
columnCount: number;
updatedAt: string;
}
Step 2: Create settings types
Create src/types/settings.ts:
export interface AppSettings {
theme: "light" | "dark" | "system";
dataDirectory: string | null;
recentBoardIds: string[];
}
Step 3: Create Zod schemas for validation
Create src/lib/schemas.ts:
import { z } from "zod";
export const checklistItemSchema = z.object({
id: z.string(),
text: z.string(),
checked: z.boolean(),
});
export const attachmentSchema = z.object({
id: z.string(),
name: z.string(),
path: z.string(),
mode: z.enum(["link", "copy"]),
});
export const labelSchema = z.object({
id: z.string(),
name: z.string(),
color: z.string(),
});
export const cardSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().default(""),
labels: z.array(z.string()).default([]),
checklist: z.array(checklistItemSchema).default([]),
dueDate: z.string().nullable().default(null),
attachments: z.array(attachmentSchema).default([]),
createdAt: z.string(),
updatedAt: z.string(),
});
export const columnSchema = z.object({
id: z.string(),
title: z.string(),
cardIds: z.array(z.string()).default([]),
width: z.enum(["narrow", "standard", "wide"]).default("standard"),
});
export const boardSettingsSchema = z.object({
attachmentMode: z.enum(["link", "copy"]).default("link"),
});
export const boardSchema = z.object({
id: z.string(),
title: z.string(),
color: z.string().default("#4a9d7f"),
createdAt: z.string(),
updatedAt: z.string(),
columns: z.array(columnSchema).default([]),
cards: z.record(z.string(), cardSchema).default({}),
labels: z.array(labelSchema).default([]),
settings: boardSettingsSchema.default({}),
});
export const appSettingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]).default("system"),
dataDirectory: z.string().nullable().default(null),
recentBoardIds: z.array(z.string()).default([]),
});
Step 4: Commit
git add src/types/ src/lib/schemas.ts
git commit -m "feat: add board/settings type definitions and Zod validation schemas"
Task 3: Filesystem Persistence Layer
Files:
- Create:
src/lib/storage.ts - Create:
src/lib/storage.test.ts
Step 1: Create the storage module
Create src/lib/storage.ts:
import {
exists,
mkdir,
readTextFile,
writeTextFile,
readDir,
remove,
copyFile,
BaseDirectory,
} from "@tauri-apps/plugin-fs";
import { appDataDir, join } from "@tauri-apps/api/path";
import { boardSchema, appSettingsSchema } from "./schemas";
import type { Board, BoardMeta } from "../types/board";
import type { AppSettings } from "../types/settings";
const APP_DIR_NAME = "openpylon";
async function getAppDir(): Promise<string> {
const base = await appDataDir();
return await join(base, APP_DIR_NAME);
}
async function getBoardsDir(): Promise<string> {
const appDir = await getAppDir();
return await join(appDir, "boards");
}
async function getAttachmentsDir(boardId: string): Promise<string> {
const appDir = await getAppDir();
return await join(appDir, "attachments", boardId);
}
export async function ensureDataDirs(): Promise<void> {
const appDir = await getAppDir();
const boardsDir = await getBoardsDir();
const attachDir = await join(appDir, "attachments");
for (const dir of [appDir, boardsDir, attachDir]) {
if (!(await exists(dir))) {
await mkdir(dir, { recursive: true });
}
}
}
export async function loadSettings(): Promise<AppSettings> {
const appDir = await getAppDir();
const settingsPath = await join(appDir, "settings.json");
if (!(await exists(settingsPath))) {
const defaults: AppSettings = {
theme: "system",
dataDirectory: null,
recentBoardIds: [],
};
await writeTextFile(settingsPath, JSON.stringify(defaults, null, 2));
return defaults;
}
const raw = await readTextFile(settingsPath);
return appSettingsSchema.parse(JSON.parse(raw));
}
export async function saveSettings(settings: AppSettings): Promise<void> {
const appDir = await getAppDir();
const settingsPath = await join(appDir, "settings.json");
await writeTextFile(settingsPath, JSON.stringify(settings, null, 2));
}
export async function listBoards(): Promise<BoardMeta[]> {
const boardsDir = await getBoardsDir();
if (!(await exists(boardsDir))) {
return [];
}
const entries = await readDir(boardsDir);
const metas: BoardMeta[] = [];
for (const entry of entries) {
if (!entry.name?.endsWith(".json") || entry.name.endsWith(".backup.json")) {
continue;
}
try {
const filePath = await join(boardsDir, entry.name);
const raw = await readTextFile(filePath);
const board = boardSchema.parse(JSON.parse(raw));
metas.push({
id: board.id,
title: board.title,
color: board.color,
cardCount: Object.keys(board.cards).length,
columnCount: board.columns.length,
updatedAt: board.updatedAt,
});
} catch {
// Skip corrupted files
}
}
return metas.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
export async function loadBoard(boardId: string): Promise<Board> {
const boardsDir = await getBoardsDir();
const filePath = await join(boardsDir, `board-${boardId}.json`);
const raw = await readTextFile(filePath);
return boardSchema.parse(JSON.parse(raw));
}
export async function saveBoard(board: Board): Promise<void> {
const boardsDir = await getBoardsDir();
const filePath = await join(boardsDir, `board-${board.id}.json`);
const backupPath = await join(boardsDir, `board-${board.id}.backup.json`);
// Rotate current to backup before writing new version
if (await exists(filePath)) {
try {
await copyFile(filePath, backupPath);
} catch {
// Backup failure is non-fatal
}
}
await writeTextFile(filePath, JSON.stringify(board, null, 2));
}
export async function deleteBoard(boardId: string): Promise<void> {
const boardsDir = await getBoardsDir();
const filePath = await join(boardsDir, `board-${boardId}.json`);
const backupPath = await join(boardsDir, `board-${boardId}.backup.json`);
if (await exists(filePath)) await remove(filePath);
if (await exists(backupPath)) await remove(backupPath);
// Remove attachments dir too
const attachDir = await getAttachmentsDir(boardId);
if (await exists(attachDir)) await remove(attachDir, { recursive: true });
}
export async function searchAllBoards(
query: string
): Promise<Array<{ boardId: string; boardTitle: string; card: { id: string; title: string } }>> {
const boardsDir = await getBoardsDir();
const entries = await readDir(boardsDir);
const results: Array<{ boardId: string; boardTitle: string; card: { id: string; title: string } }> = [];
const lowerQuery = query.toLowerCase();
for (const entry of entries) {
if (!entry.name?.endsWith(".json") || entry.name.endsWith(".backup.json")) {
continue;
}
try {
const filePath = await join(boardsDir, entry.name);
const raw = await readTextFile(filePath);
const board = boardSchema.parse(JSON.parse(raw));
for (const card of Object.values(board.cards)) {
if (
card.title.toLowerCase().includes(lowerQuery) ||
card.description.toLowerCase().includes(lowerQuery)
) {
results.push({
boardId: board.id,
boardTitle: board.title,
card: { id: card.id, title: card.title },
});
}
}
} catch {
// Skip corrupted files
}
}
return results;
}
export async function restoreFromBackup(boardId: string): Promise<Board | null> {
const boardsDir = await getBoardsDir();
const backupPath = await join(boardsDir, `board-${boardId}.backup.json`);
if (!(await exists(backupPath))) return null;
const raw = await readTextFile(backupPath);
return boardSchema.parse(JSON.parse(raw));
}
Step 2: Commit
git add src/lib/storage.ts
git commit -m "feat: add filesystem persistence layer for boards and settings"
Task 4: Zustand Stores
Files:
- Create:
src/stores/app-store.ts - Create:
src/stores/board-store.ts
Step 1: Create the app store
Create src/stores/app-store.ts:
import { create } from "zustand";
import type { AppSettings } from "../types/settings";
import type { BoardMeta } from "../types/board";
import { loadSettings, saveSettings, listBoards } from "../lib/storage";
type View = { type: "board-list" } | { type: "board"; boardId: string };
interface AppState {
settings: AppSettings;
boards: BoardMeta[];
view: View;
initialized: boolean;
init: () => Promise<void>;
setTheme: (theme: AppSettings["theme"]) => void;
setView: (view: View) => void;
refreshBoards: () => Promise<void>;
addRecentBoard: (boardId: string) => void;
}
export const useAppStore = create<AppState>((set, get) => ({
settings: { theme: "system", dataDirectory: null, recentBoardIds: [] },
boards: [],
view: { type: "board-list" },
initialized: false,
init: async () => {
const settings = await loadSettings();
const boards = await listBoards();
set({ settings, boards, initialized: true });
applyTheme(settings.theme);
},
setTheme: (theme) => {
const settings = { ...get().settings, theme };
set({ settings });
saveSettings(settings);
applyTheme(theme);
},
setView: (view) => set({ view }),
refreshBoards: async () => {
const boards = await listBoards();
set({ boards });
},
addRecentBoard: (boardId) => {
const settings = get().settings;
const recent = [boardId, ...settings.recentBoardIds.filter((id) => id !== boardId)].slice(0, 10);
const updated = { ...settings, recentBoardIds: recent };
set({ settings: updated });
saveSettings(updated);
},
}));
function applyTheme(theme: AppSettings["theme"]): void {
const root = document.documentElement;
if (theme === "system") {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.toggle("dark", prefersDark);
} else {
root.classList.toggle("dark", theme === "dark");
}
}
Step 2: Create the board store with undo/redo
Create src/stores/board-store.ts:
import { create } from "zustand";
import { temporal } from "zundo";
import { ulid } from "ulid";
import type { Board, Card, Column, Label, ChecklistItem, Attachment, ColumnWidth } from "../types/board";
import { saveBoard, loadBoard } from "../lib/storage";
interface BoardState {
board: Board | null;
saving: boolean;
lastSaved: number | null;
}
interface BoardActions {
// Board lifecycle
openBoard: (boardId: string) => Promise<void>;
closeBoard: () => void;
// Column actions
addColumn: (title: string) => void;
updateColumnTitle: (columnId: string, title: string) => void;
deleteColumn: (columnId: string) => void;
moveColumn: (fromIndex: number, toIndex: number) => void;
setColumnWidth: (columnId: string, width: ColumnWidth) => void;
// Card actions
addCard: (columnId: string, title: string) => void;
updateCard: (cardId: string, updates: Partial<Card>) => void;
deleteCard: (cardId: string) => void;
moveCard: (cardId: string, fromColumnId: string, toColumnId: string, toIndex: number) => void;
// Label actions
addLabel: (name: string, color: string) => void;
updateLabel: (labelId: string, updates: Partial<Label>) => void;
deleteLabel: (labelId: string) => void;
toggleCardLabel: (cardId: string, labelId: string) => void;
// Checklist actions
addChecklistItem: (cardId: string, text: string) => void;
toggleChecklistItem: (cardId: string, itemId: string) => void;
updateChecklistItem: (cardId: string, itemId: string, text: string) => void;
deleteChecklistItem: (cardId: string, itemId: string) => void;
// Attachment actions
addAttachment: (cardId: string, attachment: Omit<Attachment, "id">) => void;
removeAttachment: (cardId: string, attachmentId: string) => void;
// Board settings
updateBoardTitle: (title: string) => void;
updateBoardColor: (color: string) => void;
updateBoardSettings: (settings: Board["settings"]) => void;
}
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
function debouncedSave(board: Board, set: (state: Partial<BoardState>) => void): void {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
set({ saving: true });
await saveBoard(board);
set({ saving: false, lastSaved: Date.now() });
}, 500);
}
function now(): string {
return new Date().toISOString();
}
export const useBoardStore = create<BoardState & BoardActions>()(
temporal(
(set, get) => ({
board: null,
saving: false,
lastSaved: null,
openBoard: async (boardId: string) => {
const board = await loadBoard(boardId);
set({ board, saving: false, lastSaved: null });
},
closeBoard: () => {
// Flush any pending save
if (saveTimeout) {
clearTimeout(saveTimeout);
const { board } = get();
if (board) saveBoard(board);
}
set({ board: null, saving: false, lastSaved: null });
},
addColumn: (title: string) => {
const { board } = get();
if (!board) return;
const updated: Board = {
...board,
updatedAt: now(),
columns: [...board.columns, { id: ulid(), title, cardIds: [], width: "standard" }],
};
set({ board: updated });
debouncedSave(updated, set);
},
updateColumnTitle: (columnId, title) => {
const { board } = get();
if (!board) return;
const updated: Board = {
...board,
updatedAt: now(),
columns: board.columns.map((c) => (c.id === columnId ? { ...c, title } : c)),
};
set({ board: updated });
debouncedSave(updated, set);
},
deleteColumn: (columnId) => {
const { board } = get();
if (!board) return;
const col = board.columns.find((c) => c.id === columnId);
if (!col) return;
const newCards = { ...board.cards };
col.cardIds.forEach((id) => delete newCards[id]);
const updated: Board = {
...board,
updatedAt: now(),
columns: board.columns.filter((c) => c.id !== columnId),
cards: newCards,
};
set({ board: updated });
debouncedSave(updated, set);
},
moveColumn: (fromIndex, toIndex) => {
const { board } = get();
if (!board) return;
const cols = [...board.columns];
const [moved] = cols.splice(fromIndex, 1);
cols.splice(toIndex, 0, moved);
const updated: Board = { ...board, updatedAt: now(), columns: cols };
set({ board: updated });
debouncedSave(updated, set);
},
setColumnWidth: (columnId, width) => {
const { board } = get();
if (!board) return;
const updated: Board = {
...board,
columns: board.columns.map((c) => (c.id === columnId ? { ...c, width } : c)),
};
set({ board: updated });
debouncedSave(updated, set);
},
addCard: (columnId, title) => {
const { board } = get();
if (!board) return;
const cardId = ulid();
const card: Card = {
id: cardId,
title,
description: "",
labels: [],
checklist: [],
dueDate: null,
attachments: [],
createdAt: now(),
updatedAt: now(),
};
const updated: Board = {
...board,
updatedAt: now(),
cards: { ...board.cards, [cardId]: card },
columns: board.columns.map((c) =>
c.id === columnId ? { ...c, cardIds: [...c.cardIds, cardId] } : c
),
};
set({ board: updated });
debouncedSave(updated, set);
},
updateCard: (cardId, updates) => {
const { board } = get();
if (!board || !board.cards[cardId]) return;
const updated: Board = {
...board,
updatedAt: now(),
cards: {
...board.cards,
[cardId]: { ...board.cards[cardId], ...updates, updatedAt: now() },
},
};
set({ board: updated });
debouncedSave(updated, set);
},
deleteCard: (cardId) => {
const { board } = get();
if (!board) return;
const newCards = { ...board.cards };
delete newCards[cardId];
const updated: Board = {
...board,
updatedAt: now(),
cards: newCards,
columns: board.columns.map((c) => ({
...c,
cardIds: c.cardIds.filter((id) => id !== cardId),
})),
};
set({ board: updated });
debouncedSave(updated, set);
},
moveCard: (cardId, fromColumnId, toColumnId, toIndex) => {
const { board } = get();
if (!board) return;
const updated: Board = {
...board,
updatedAt: now(),
columns: board.columns.map((c) => {
if (c.id === fromColumnId && c.id === toColumnId) {
const ids = c.cardIds.filter((id) => id !== cardId);
ids.splice(toIndex, 0, cardId);
return { ...c, cardIds: ids };
}
if (c.id === fromColumnId) {
return { ...c, cardIds: c.cardIds.filter((id) => id !== cardId) };
}
if (c.id === toColumnId) {
const ids = [...c.cardIds];
ids.splice(toIndex, 0, cardId);
return { ...c, cardIds: ids };
}
return c;
}),
};
set({ board: updated });
debouncedSave(updated, set);
},
addLabel: (name, color) => {
const { board } = get();
if (!board) return;
const updated: Board = {
...board,
updatedAt: now(),
labels: [...board.labels, { id: ulid(), name, color }],
};
set({ board: updated });
debouncedSave(updated, set);
},
updateLabel: (labelId, updates) => {
const { board } = get();
if (!board) return;
const updated: Board = {
...board,
updatedAt: now(),
labels: board.labels.map((l) => (l.id === labelId ? { ...l, ...updates } : l)),
};
set({ board: updated });
debouncedSave(updated, set);
},
deleteLabel: (labelId) => {
const { board } = get();
if (!board) return;
const updated: Board = {
...board,
updatedAt: now(),
labels: board.labels.filter((l) => l.id !== labelId),
cards: Object.fromEntries(
Object.entries(board.cards).map(([id, card]) => [
id,
{ ...card, labels: card.labels.filter((l) => l !== labelId) },
])
),
};
set({ board: updated });
debouncedSave(updated, set);
},
toggleCardLabel: (cardId, labelId) => {
const { board } = get();
if (!board || !board.cards[cardId]) return;
const card = board.cards[cardId];
const labels = card.labels.includes(labelId)
? card.labels.filter((l) => l !== labelId)
: [...card.labels, labelId];
const updated: Board = {
...board,
updatedAt: now(),
cards: { ...board.cards, [cardId]: { ...card, labels, updatedAt: now() } },
};
set({ board: updated });
debouncedSave(updated, set);
},
addChecklistItem: (cardId, text) => {
const { board } = get();
if (!board || !board.cards[cardId]) return;
const card = board.cards[cardId];
const item: ChecklistItem = { id: ulid(), text, checked: false };
const updated: Board = {
...board,
updatedAt: now(),
cards: {
...board.cards,
[cardId]: { ...card, checklist: [...card.checklist, item], updatedAt: now() },
},
};
set({ board: updated });
debouncedSave(updated, set);
},
toggleChecklistItem: (cardId, itemId) => {
const { board } = get();
if (!board || !board.cards[cardId]) return;
const card = board.cards[cardId];
const updated: Board = {
...board,
updatedAt: now(),
cards: {
...board.cards,
[cardId]: {
...card,
updatedAt: now(),
checklist: card.checklist.map((item) =>
item.id === itemId ? { ...item, checked: !item.checked } : item
),
},
},
};
set({ board: updated });
debouncedSave(updated, set);
},
updateChecklistItem: (cardId, itemId, text) => {
const { board } = get();
if (!board || !board.cards[cardId]) return;
const card = board.cards[cardId];
const updated: Board = {
...board,
updatedAt: now(),
cards: {
...board.cards,
[cardId]: {
...card,
updatedAt: now(),
checklist: card.checklist.map((item) =>
item.id === itemId ? { ...item, text } : item
),
},
},
};
set({ board: updated });
debouncedSave(updated, set);
},
deleteChecklistItem: (cardId, itemId) => {
const { board } = get();
if (!board || !board.cards[cardId]) return;
const card = board.cards[cardId];
const updated: Board = {
...board,
updatedAt: now(),
cards: {
...board.cards,
[cardId]: {
...card,
updatedAt: now(),
checklist: card.checklist.filter((item) => item.id !== itemId),
},
},
};
set({ board: updated });
debouncedSave(updated, set);
},
addAttachment: (cardId, attachment) => {
const { board } = get();
if (!board || !board.cards[cardId]) return;
const card = board.cards[cardId];
const updated: Board = {
...board,
updatedAt: now(),
cards: {
...board.cards,
[cardId]: {
...card,
updatedAt: now(),
attachments: [...card.attachments, { ...attachment, id: ulid() }],
},
},
};
set({ board: updated });
debouncedSave(updated, set);
},
removeAttachment: (cardId, attachmentId) => {
const { board } = get();
if (!board || !board.cards[cardId]) return;
const card = board.cards[cardId];
const updated: Board = {
...board,
updatedAt: now(),
cards: {
...board.cards,
[cardId]: {
...card,
updatedAt: now(),
attachments: card.attachments.filter((a) => a.id !== attachmentId),
},
},
};
set({ board: updated });
debouncedSave(updated, set);
},
updateBoardTitle: (title) => {
const { board } = get();
if (!board) return;
const updated: Board = { ...board, title, updatedAt: now() };
set({ board: updated });
debouncedSave(updated, set);
},
updateBoardColor: (color) => {
const { board } = get();
if (!board) return;
const updated: Board = { ...board, color, updatedAt: now() };
set({ board: updated });
debouncedSave(updated, set);
},
updateBoardSettings: (settings) => {
const { board } = get();
if (!board) return;
const updated: Board = { ...board, settings, updatedAt: now() };
set({ board: updated });
debouncedSave(updated, set);
},
}),
{
limit: 50,
partialize: (state) => {
const { board } = state;
return { board };
},
}
)
);
Step 3: Commit
git add src/stores/
git commit -m "feat: add Zustand stores with undo/redo and debounced persistence"
Task 5: Tailwind Theme + Font Setup
Files:
- Modify:
src/index.css(or Tailwind base file) - Modify:
tailwind.config.ts - Create:
src/styles/fonts.css
Step 1: Download and set up fonts
Download Instrument Serif, Satoshi, and Geist Mono as woff2 files and place in src/assets/fonts/. Alternatively, use Google Fonts for Instrument Serif and CDN links for Satoshi/Geist Mono.
Create src/styles/fonts.css:
@font-face {
font-family: "Instrument Serif";
src: url("https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap");
font-display: swap;
}
/* Satoshi from fontshare.com — self-host the woff2 files */
@font-face {
font-family: "Satoshi";
src: url("../assets/fonts/Satoshi-Variable.woff2") format("woff2");
font-weight: 300 900;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src: url("../assets/fonts/GeistMono-Variable.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
Step 2: Configure Tailwind with custom theme
Update tailwind.config.ts:
import type { Config } from "tailwindcss";
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
heading: ['"Instrument Serif"', "serif"],
body: ["Satoshi", "sans-serif"],
mono: ['"Geist Mono"', "monospace"],
},
colors: {
pylon: {
bg: "oklch(var(--color-bg) / <alpha-value>)",
surface: "oklch(var(--color-surface) / <alpha-value>)",
column: "oklch(var(--color-column) / <alpha-value>)",
accent: "oklch(var(--color-accent) / <alpha-value>)",
text: "oklch(var(--color-text) / <alpha-value>)",
"text-secondary": "oklch(var(--color-text-secondary) / <alpha-value>)",
danger: "oklch(var(--color-danger) / <alpha-value>)",
},
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
Step 3: Set CSS custom properties for light/dark
In src/index.css (add to the existing file after Tailwind directives):
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "./styles/fonts.css";
@layer base {
:root {
--color-bg: 97% 0.005 80;
--color-surface: 99% 0.003 80;
--color-column: 95% 0.008 80;
--color-accent: 55% 0.12 160;
--color-text: 25% 0.015 50;
--color-text-secondary: 55% 0.01 50;
--color-danger: 55% 0.18 25;
}
.dark {
--color-bg: 18% 0.01 50;
--color-surface: 22% 0.01 50;
--color-column: 20% 0.012 50;
--color-accent: 60% 0.12 160;
--color-text: 90% 0.01 50;
--color-text-secondary: 55% 0.01 50;
--color-danger: 60% 0.18 25;
}
body {
font-family: "Satoshi", sans-serif;
background: oklch(var(--color-bg));
color: oklch(var(--color-text));
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
}
Step 4: Commit
git add src/styles/ src/index.css tailwind.config.ts src/assets/
git commit -m "feat: configure custom theme with OKLCH colors, fonts, and dark mode"
Task 6: App Shell + Router
Files:
- Modify:
src/App.tsx - Create:
src/components/layout/AppShell.tsx - Create:
src/components/layout/TopBar.tsx
Step 1: Create the app shell
Create src/components/layout/TopBar.tsx:
import { useAppStore } from "../../stores/app-store";
import { useBoardStore } from "../../stores/board-store";
import { useState, useRef, useEffect } from "react";
import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function TopBar() {
const { view, setView } = useAppStore();
const { board, updateBoardTitle, saving, lastSaved } = useBoardStore();
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const isBoard = view.type === "board" && board;
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const handleTitleSave = () => {
if (title.trim()) updateBoardTitle(title.trim());
setEditing(false);
};
return (
<header className="flex items-center justify-between h-12 px-4 border-b border-pylon-column">
<div className="flex items-center gap-3">
{isBoard && (
<button
onClick={() => {
useBoardStore.getState().closeBoard();
setView({ type: "board-list" });
}}
className="text-pylon-text-secondary hover:text-pylon-text text-sm font-mono"
>
← Boards
</button>
)}
{isBoard && !editing && (
<h1
className="font-heading text-lg cursor-text"
onClick={() => {
setTitle(board.title);
setEditing(true);
}}
>
{board.title}
</h1>
)}
{isBoard && editing && (
<input
ref={inputRef}
value={title}
onChange={(e) => 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 && (
<h1 className="font-heading text-lg">OpenPylon</h1>
)}
</div>
<div className="flex items-center gap-2">
{saving && (
<span className="text-xs font-mono text-pylon-text-secondary">Saving...</span>
)}
{!saving && lastSaved && (
<span className="text-xs font-mono text-pylon-text-secondary">Saved</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="font-mono text-xs"
onClick={() => {
// Will be wired to command palette
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
}}
>
⌘K
</Button>
</TooltipTrigger>
<TooltipContent>Command palette</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
// Will open settings dialog
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</div>
</header>
);
}
Step 2: Create the app shell
Create src/components/layout/AppShell.tsx:
import { TopBar } from "./TopBar";
import { TooltipProvider } from "../ui/tooltip";
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<TooltipProvider delayDuration={300}>
<div className="h-screen flex flex-col bg-pylon-bg text-pylon-text">
<TopBar />
<main className="flex-1 overflow-hidden">{children}</main>
</div>
</TooltipProvider>
);
}
Step 3: Wire up App.tsx with routing by view state
Replace src/App.tsx:
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 (
<div className="h-screen flex items-center justify-center bg-pylon-bg">
<span className="font-mono text-pylon-text-secondary text-sm">Loading...</span>
</div>
);
}
return (
<AppShell>
{view.type === "board-list" && <BoardList />}
{view.type === "board" && <BoardView />}
</AppShell>
);
}
Step 4: Commit
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:
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
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
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
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
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
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 <CommandPalette /> as a sibling to the main content in App.tsx. It renders as a Dialog overlay.
Step 4: Commit
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
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 paletteCtrl+N→ new board dialogCtrl+Z→useBoardStore.temporal.getState().undo()Ctrl+Shift+Z→useBoardStore.temporal.getState().redo()Ctrl+,→ open settingsEscape→ close active modalN(no modifier, board view, no input focused) → new card in focused column- Arrow keys → column/card focus navigation
Enter→ open focused card detailD→ due date on focused cardL→ label picker on focused card
Use a focus tracking state (focusedColumnIndex, focusedCardIndex) stored in a ref to avoid re-renders.
Step 2: Commit
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 JSONexportBoardAsCsv(board)— flattens cards to CSV rows, Tauri save dialogimportBoardFromJson(filePath)— read file, validate with Zod, return BoardimportBoardFromCsv(filePath)— parse CSV, map columns, create BoardimportFromTrello(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
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
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 <section> with aria-label, card lists use <ul>/<li>, buttons are <button> not <div onClick>.
Step 2: Add ARIA attributes
- Column headers:
aria-label="To Do column, 4 cards" - Cards:
role="article",aria-labelwith card title - Drag and drop: dnd-kit
announcementsprop for screen reader updates - Modals:
aria-modal="true", focus trap via shadcn Dialog (built-in) aria-live="polite"region for save status announcements
Step 3: Focus indicators
Ensure all interactive elements have visible focus via:
:focus-visible {
outline: 2px solid oklch(var(--color-accent));
outline-offset: 2px;
}
Step 4: High contrast support
@media (prefers-contrast: more) {
/* Restore card borders, increase shadow intensity */
}
Step 5: Commit
git add -A
git commit -m "feat: accessibility pass — semantic HTML, ARIA, focus indicators, high contrast"
Task 18: Window Close Handler + Final Polish
Files:
- Modify:
src/App.tsx - Modify:
src-tauri/tauri.conf.json(window config)
Step 1: Handle window close
In App.tsx, use Tauri's onCloseRequested to flush any pending board saves before the app exits:
import { getCurrentWindow } from "@tauri-apps/api/window";
useEffect(() => {
const unlisten = getCurrentWindow().onCloseRequested(async (event) => {
useBoardStore.getState().closeBoard();
});
return () => { unlisten.then((fn) => fn()); };
}, []);
Step 2: Configure Tauri window
In src-tauri/tauri.conf.json, set:
{
"app": {
"windows": [
{
"title": "OpenPylon",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600
}
]
}
}
Step 3: Final build test
Run:
npm run tauri build
Expected: Builds a production executable.
Step 4: Commit
git add -A
git commit -m "feat: add window close handler, configure window size, production build ready"
Task Dependency Map
Task 1 (scaffold) ─────────────────────────────────────────────┐
│ │
Task 2 (types + schemas) │
│ │
Task 3 (storage layer) │
│ │
Task 4 (Zustand stores) ──────────────────┐ │
│ │ │
Task 5 (theme + fonts) ──────────────────────────────────────────
│ │
Task 6 (app shell + router) │
│ │
├── Task 7 (board list) │
│ │ │
│ ├── Task 15 (import/export) │
│ │
├── Task 8 (columns) │
│ │ │
│ ├── Task 9 (card thumbnails) │
│ │ │ │
│ │ ├── Task 10 (drag & drop) ──┘
│ │ │
│ │ ├── Task 11 (card detail modal)
│ │
│ ├── Task 12 (command palette)
│
├── Task 13 (settings dialog)
│
├── Task 14 (keyboard shortcuts)
│
├── Task 16 (animations) ── requires Tasks 9, 10, 11
│
├── Task 17 (accessibility) ── requires all UI tasks
│
└── Task 18 (close handler + polish) ── final task
Total Estimated Scope
- 18 tasks, ordered by dependency
- Tasks 1-5: Foundation (scaffold, types, storage, stores, theme)
- Tasks 6-9: Core UI (shell, board list, columns, cards)
- Tasks 10-12: Interactivity (drag-and-drop, card detail, command palette)
- Tasks 13-15: Features (settings, shortcuts, import/export)
- Tasks 16-18: Polish (animations, accessibility, final build)