Files
openpylon/docs/plans/2026-02-15-openpylon-implementation.md
Your Name d167d0aa0b Add OpenPylon implementation plan (18 tasks)
Detailed step-by-step plan covering scaffold, types, storage,
stores, UI components, drag-and-drop, command palette, import/export,
animations, accessibility, and production build.
2026-02-15 18:15:14 +02:00

1892 lines
56 KiB
Markdown

# OpenPylon Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a local-first Kanban board desktop app using Tauri v2 + React + TypeScript that saves to local JSON files.
**Architecture:** Monolithic Zustand store per board, loaded from JSON on open, debounced writes on mutation. Tauri v2 handles filesystem access. React + shadcn/ui + Tailwind for UI, dnd-kit for drag-and-drop.
**Tech Stack:** Tauri v2, React 19, TypeScript, Zustand, zundo, dnd-kit, shadcn/ui, Tailwind CSS, Framer Motion, Zod, cmdk, ulid
---
## Task 1: Scaffold Tauri + React Project
**Files:**
- Create: entire project scaffold via `create-tauri-app`
- Modify: `package.json` (add dependencies)
- Modify: `src-tauri/Cargo.toml` (add plugins)
- Modify: `src-tauri/capabilities/default.json` (filesystem permissions)
**Step 1: Create the Tauri app**
Run:
```bash
cd D:/gdfhbfgdbnbdfbdf/openpylon
npm create tauri-app@latest . -- --template react-ts --manager npm
```
If the interactive prompt appears, select:
- Project name: `openpylon`
- Frontend: React
- Language: TypeScript
- Package manager: npm
**Step 2: Install Tauri filesystem plugin**
Run:
```bash
cd D:/gdfhbfgdbnbdfbdf/openpylon
npm install @tauri-apps/plugin-fs @tauri-apps/plugin-dialog @tauri-apps/plugin-shell
```
Add the plugin to `src-tauri/Cargo.toml` dependencies:
```toml
[dependencies]
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
```
Register plugins in `src-tauri/src/lib.rs`:
```rust
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
**Step 3: Configure filesystem permissions**
Modify `src-tauri/capabilities/default.json` to include:
```json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for OpenPylon",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"shell:default",
{
"identifier": "fs:default",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-exists",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-read",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-write",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
},
{
"identifier": "fs:allow-mkdir",
"allow": [{ "path": "$APPDATA/openpylon/**" }]
}
]
}
```
**Step 4: Install all frontend dependencies**
Run:
```bash
npm install zustand zundo @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities framer-motion zod ulid date-fns react-markdown remark-gfm
```
**Step 5: Install and init shadcn/ui + Tailwind**
Run:
```bash
npx shadcn@latest init
```
Select: TypeScript, default style, base color neutral, CSS variables yes.
Then add required components:
```bash
npx shadcn@latest add dialog dropdown-menu tooltip command context-menu popover button input scroll-area badge separator textarea
```
**Step 6: Verify build**
Run:
```bash
npm run tauri dev
```
Expected: Tauri dev window opens with default React template.
**Step 7: Commit**
```bash
git add -A
git commit -m "feat: scaffold Tauri v2 + React + TS project with all dependencies"
```
---
## Task 2: Type Definitions + Zod Schemas
**Files:**
- Create: `src/types/board.ts`
- Create: `src/types/settings.ts`
- Create: `src/lib/schemas.ts`
**Step 1: Create board type definitions**
Create `src/types/board.ts`:
```typescript
export interface Board {
id: string;
title: string;
color: string;
createdAt: string;
updatedAt: string;
columns: Column[];
cards: Record<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`:
```typescript
export interface AppSettings {
theme: "light" | "dark" | "system";
dataDirectory: string | null;
recentBoardIds: string[];
}
```
**Step 3: Create Zod schemas for validation**
Create `src/lib/schemas.ts`:
```typescript
import { z } from "zod";
export const checklistItemSchema = z.object({
id: z.string(),
text: z.string(),
checked: z.boolean(),
});
export const attachmentSchema = z.object({
id: z.string(),
name: z.string(),
path: z.string(),
mode: z.enum(["link", "copy"]),
});
export const labelSchema = z.object({
id: z.string(),
name: z.string(),
color: z.string(),
});
export const cardSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().default(""),
labels: z.array(z.string()).default([]),
checklist: z.array(checklistItemSchema).default([]),
dueDate: z.string().nullable().default(null),
attachments: z.array(attachmentSchema).default([]),
createdAt: z.string(),
updatedAt: z.string(),
});
export const columnSchema = z.object({
id: z.string(),
title: z.string(),
cardIds: z.array(z.string()).default([]),
width: z.enum(["narrow", "standard", "wide"]).default("standard"),
});
export const boardSettingsSchema = z.object({
attachmentMode: z.enum(["link", "copy"]).default("link"),
});
export const boardSchema = z.object({
id: z.string(),
title: z.string(),
color: z.string().default("#4a9d7f"),
createdAt: z.string(),
updatedAt: z.string(),
columns: z.array(columnSchema).default([]),
cards: z.record(z.string(), cardSchema).default({}),
labels: z.array(labelSchema).default([]),
settings: boardSettingsSchema.default({}),
});
export const appSettingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]).default("system"),
dataDirectory: z.string().nullable().default(null),
recentBoardIds: z.array(z.string()).default([]),
});
```
**Step 4: Commit**
```bash
git add src/types/ src/lib/schemas.ts
git commit -m "feat: add board/settings type definitions and Zod validation schemas"
```
---
## Task 3: Filesystem Persistence Layer
**Files:**
- Create: `src/lib/storage.ts`
- Create: `src/lib/storage.test.ts`
**Step 1: Create the storage module**
Create `src/lib/storage.ts`:
```typescript
import {
exists,
mkdir,
readTextFile,
writeTextFile,
readDir,
remove,
copyFile,
BaseDirectory,
} from "@tauri-apps/plugin-fs";
import { appDataDir, join } from "@tauri-apps/api/path";
import { boardSchema, appSettingsSchema } from "./schemas";
import type { Board, BoardMeta } from "../types/board";
import type { AppSettings } from "../types/settings";
const APP_DIR_NAME = "openpylon";
async function getAppDir(): Promise<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**
```bash
git add src/lib/storage.ts
git commit -m "feat: add filesystem persistence layer for boards and settings"
```
---
## Task 4: Zustand Stores
**Files:**
- Create: `src/stores/app-store.ts`
- Create: `src/stores/board-store.ts`
**Step 1: Create the app store**
Create `src/stores/app-store.ts`:
```typescript
import { create } from "zustand";
import type { AppSettings } from "../types/settings";
import type { BoardMeta } from "../types/board";
import { loadSettings, saveSettings, listBoards } from "../lib/storage";
type View = { type: "board-list" } | { type: "board"; boardId: string };
interface AppState {
settings: AppSettings;
boards: BoardMeta[];
view: View;
initialized: boolean;
init: () => Promise<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`:
```typescript
import { create } from "zustand";
import { temporal } from "zundo";
import { ulid } from "ulid";
import type { Board, Card, Column, Label, ChecklistItem, Attachment, ColumnWidth } from "../types/board";
import { saveBoard, loadBoard } from "../lib/storage";
interface BoardState {
board: Board | null;
saving: boolean;
lastSaved: number | null;
}
interface BoardActions {
// Board lifecycle
openBoard: (boardId: string) => Promise<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**
```bash
git add src/stores/
git commit -m "feat: add Zustand stores with undo/redo and debounced persistence"
```
---
## Task 5: Tailwind Theme + Font Setup
**Files:**
- Modify: `src/index.css` (or Tailwind base file)
- Modify: `tailwind.config.ts`
- Create: `src/styles/fonts.css`
**Step 1: Download and set up fonts**
Download Instrument Serif, Satoshi, and Geist Mono as woff2 files and place in `src/assets/fonts/`. Alternatively, use Google Fonts for Instrument Serif and CDN links for Satoshi/Geist Mono.
Create `src/styles/fonts.css`:
```css
@font-face {
font-family: "Instrument Serif";
src: url("https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap");
font-display: swap;
}
/* Satoshi from fontshare.com — self-host the woff2 files */
@font-face {
font-family: "Satoshi";
src: url("../assets/fonts/Satoshi-Variable.woff2") format("woff2");
font-weight: 300 900;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src: url("../assets/fonts/GeistMono-Variable.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
```
**Step 2: Configure Tailwind with custom theme**
Update `tailwind.config.ts`:
```typescript
import type { Config } from "tailwindcss";
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
heading: ['"Instrument Serif"', "serif"],
body: ["Satoshi", "sans-serif"],
mono: ['"Geist Mono"', "monospace"],
},
colors: {
pylon: {
bg: "oklch(var(--color-bg) / <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):
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "./styles/fonts.css";
@layer base {
:root {
--color-bg: 97% 0.005 80;
--color-surface: 99% 0.003 80;
--color-column: 95% 0.008 80;
--color-accent: 55% 0.12 160;
--color-text: 25% 0.015 50;
--color-text-secondary: 55% 0.01 50;
--color-danger: 55% 0.18 25;
}
.dark {
--color-bg: 18% 0.01 50;
--color-surface: 22% 0.01 50;
--color-column: 20% 0.012 50;
--color-accent: 60% 0.12 160;
--color-text: 90% 0.01 50;
--color-text-secondary: 55% 0.01 50;
--color-danger: 60% 0.18 25;
}
body {
font-family: "Satoshi", sans-serif;
background: oklch(var(--color-bg));
color: oklch(var(--color-text));
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
}
```
**Step 4: Commit**
```bash
git add src/styles/ src/index.css tailwind.config.ts src/assets/
git commit -m "feat: configure custom theme with OKLCH colors, fonts, and dark mode"
```
---
## Task 6: App Shell + Router
**Files:**
- Modify: `src/App.tsx`
- Create: `src/components/layout/AppShell.tsx`
- Create: `src/components/layout/TopBar.tsx`
**Step 1: Create the app shell**
Create `src/components/layout/TopBar.tsx`:
```typescript
import { useAppStore } from "../../stores/app-store";
import { useBoardStore } from "../../stores/board-store";
import { useState, useRef, useEffect } from "react";
import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function TopBar() {
const { view, setView } = useAppStore();
const { board, updateBoardTitle, saving, lastSaved } = useBoardStore();
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState("");
const inputRef = useRef<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"
>
&larr; 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 }));
}}
>
&#8984;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`:
```typescript
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`:
```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 (
<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**
```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 `<CommandPalette />` 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 `<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-label` with card title
- Drag and drop: dnd-kit `announcements` prop for screen reader updates
- Modals: `aria-modal="true"`, focus trap via shadcn Dialog (built-in)
- `aria-live="polite"` region for save status announcements
**Step 3: Focus indicators**
Ensure all interactive elements have visible focus via:
```css
:focus-visible {
outline: 2px solid oklch(var(--color-accent));
outline-offset: 2px;
}
```
**Step 4: High contrast support**
```css
@media (prefers-contrast: more) {
/* Restore card borders, increase shadow intensity */
}
```
**Step 5: Commit**
```bash
git add -A
git commit -m "feat: accessibility pass — semantic HTML, ARIA, focus indicators, high contrast"
```
---
## Task 18: Window Close Handler + Final Polish
**Files:**
- Modify: `src/App.tsx`
- Modify: `src-tauri/tauri.conf.json` (window config)
**Step 1: Handle window close**
In App.tsx, use Tauri's `onCloseRequested` to flush any pending board saves before the app exits:
```typescript
import { getCurrentWindow } from "@tauri-apps/api/window";
useEffect(() => {
const unlisten = getCurrentWindow().onCloseRequested(async (event) => {
useBoardStore.getState().closeBoard();
});
return () => { unlisten.then((fn) => fn()); };
}, []);
```
**Step 2: Configure Tauri window**
In `src-tauri/tauri.conf.json`, set:
```json
{
"app": {
"windows": [
{
"title": "OpenPylon",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600
}
]
}
}
```
**Step 3: Final build test**
Run:
```bash
npm run tauri build
```
Expected: Builds a production executable.
**Step 4: Commit**
```bash
git add -A
git commit -m "feat: add window close handler, configure window size, production build ready"
```
---
## Task Dependency Map
```
Task 1 (scaffold) ─────────────────────────────────────────────┐
│ │
Task 2 (types + schemas) │
│ │
Task 3 (storage layer) │
│ │
Task 4 (Zustand stores) ──────────────────┐ │
│ │ │
Task 5 (theme + fonts) ──────────────────────────────────────────
│ │
Task 6 (app shell + router) │
│ │
├── Task 7 (board list) │
│ │ │
│ ├── Task 15 (import/export) │
│ │
├── Task 8 (columns) │
│ │ │
│ ├── Task 9 (card thumbnails) │
│ │ │ │
│ │ ├── Task 10 (drag & drop) ──┘
│ │ │
│ │ ├── Task 11 (card detail modal)
│ │
│ ├── Task 12 (command palette)
├── Task 13 (settings dialog)
├── Task 14 (keyboard shortcuts)
├── Task 16 (animations) ── requires Tasks 9, 10, 11
├── Task 17 (accessibility) ── requires all UI tasks
└── Task 18 (close handler + polish) ── final task
```
---
## Total Estimated Scope
- **18 tasks**, ordered by dependency
- Tasks 1-5: Foundation (scaffold, types, storage, stores, theme)
- Tasks 6-9: Core UI (shell, board list, columns, cards)
- Tasks 10-12: Interactivity (drag-and-drop, card detail, command palette)
- Tasks 13-15: Features (settings, shortcuts, import/export)
- Tasks 16-18: Polish (animations, accessibility, final build)