# OpenPylon Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a local-first Kanban board desktop app using Tauri v2 + React + TypeScript that saves to local JSON files. **Architecture:** Monolithic Zustand store per board, loaded from JSON on open, debounced writes on mutation. Tauri v2 handles filesystem access. React + shadcn/ui + Tailwind for UI, dnd-kit for drag-and-drop. **Tech Stack:** Tauri v2, React 19, TypeScript, Zustand, zundo, dnd-kit, shadcn/ui, Tailwind CSS, Framer Motion, Zod, cmdk, ulid --- ## Task 1: Scaffold Tauri + React Project **Files:** - Create: entire project scaffold via `create-tauri-app` - Modify: `package.json` (add dependencies) - Modify: `src-tauri/Cargo.toml` (add plugins) - Modify: `src-tauri/capabilities/default.json` (filesystem permissions) **Step 1: Create the Tauri app** Run: ```bash cd D:/gdfhbfgdbnbdfbdf/openpylon npm create tauri-app@latest . -- --template react-ts --manager npm ``` If the interactive prompt appears, select: - Project name: `openpylon` - Frontend: React - Language: TypeScript - Package manager: npm **Step 2: Install Tauri filesystem plugin** Run: ```bash cd D:/gdfhbfgdbnbdfbdf/openpylon npm install @tauri-apps/plugin-fs @tauri-apps/plugin-dialog @tauri-apps/plugin-shell ``` Add the plugin to `src-tauri/Cargo.toml` dependencies: ```toml [dependencies] tauri-plugin-fs = "2" tauri-plugin-dialog = "2" tauri-plugin-shell = "2" ``` Register plugins in `src-tauri/src/lib.rs`: ```rust pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` **Step 3: Configure filesystem permissions** Modify `src-tauri/capabilities/default.json` to include: ```json { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default permissions for OpenPylon", "windows": ["main"], "permissions": [ "core:default", "dialog:default", "shell:default", { "identifier": "fs:default", "allow": [{ "path": "$APPDATA/openpylon/**" }] }, { "identifier": "fs:allow-exists", "allow": [{ "path": "$APPDATA/openpylon/**" }] }, { "identifier": "fs:allow-read", "allow": [{ "path": "$APPDATA/openpylon/**" }] }, { "identifier": "fs:allow-write", "allow": [{ "path": "$APPDATA/openpylon/**" }] }, { "identifier": "fs:allow-mkdir", "allow": [{ "path": "$APPDATA/openpylon/**" }] } ] } ``` **Step 4: Install all frontend dependencies** Run: ```bash npm install zustand zundo @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities framer-motion zod ulid date-fns react-markdown remark-gfm ``` **Step 5: Install and init shadcn/ui + Tailwind** Run: ```bash npx shadcn@latest init ``` Select: TypeScript, default style, base color neutral, CSS variables yes. Then add required components: ```bash npx shadcn@latest add dialog dropdown-menu tooltip command context-menu popover button input scroll-area badge separator textarea ``` **Step 6: Verify build** Run: ```bash npm run tauri dev ``` Expected: Tauri dev window opens with default React template. **Step 7: Commit** ```bash git add -A git commit -m "feat: scaffold Tauri v2 + React + TS project with all dependencies" ``` --- ## Task 2: Type Definitions + Zod Schemas **Files:** - Create: `src/types/board.ts` - Create: `src/types/settings.ts` - Create: `src/lib/schemas.ts` **Step 1: Create board type definitions** Create `src/types/board.ts`: ```typescript export interface Board { id: string; title: string; color: string; createdAt: string; updatedAt: string; columns: Column[]; cards: Record; labels: Label[]; settings: BoardSettings; } export interface Column { id: string; title: string; cardIds: string[]; width: ColumnWidth; } export type ColumnWidth = "narrow" | "standard" | "wide"; export interface Card { id: string; title: string; description: string; labels: string[]; checklist: ChecklistItem[]; dueDate: string | null; attachments: Attachment[]; createdAt: string; updatedAt: string; } export interface Label { id: string; name: string; color: string; } export interface ChecklistItem { id: string; text: string; checked: boolean; } export interface Attachment { id: string; name: string; path: string; mode: "link" | "copy"; } export interface BoardSettings { attachmentMode: "link" | "copy"; } export interface BoardMeta { id: string; title: string; color: string; cardCount: number; columnCount: number; updatedAt: string; } ``` **Step 2: Create settings types** Create `src/types/settings.ts`: ```typescript export interface AppSettings { theme: "light" | "dark" | "system"; dataDirectory: string | null; recentBoardIds: string[]; } ``` **Step 3: Create Zod schemas for validation** Create `src/lib/schemas.ts`: ```typescript import { z } from "zod"; export const checklistItemSchema = z.object({ id: z.string(), text: z.string(), checked: z.boolean(), }); export const attachmentSchema = z.object({ id: z.string(), name: z.string(), path: z.string(), mode: z.enum(["link", "copy"]), }); export const labelSchema = z.object({ id: z.string(), name: z.string(), color: z.string(), }); export const cardSchema = z.object({ id: z.string(), title: z.string(), description: z.string().default(""), labels: z.array(z.string()).default([]), checklist: z.array(checklistItemSchema).default([]), dueDate: z.string().nullable().default(null), attachments: z.array(attachmentSchema).default([]), createdAt: z.string(), updatedAt: z.string(), }); export const columnSchema = z.object({ id: z.string(), title: z.string(), cardIds: z.array(z.string()).default([]), width: z.enum(["narrow", "standard", "wide"]).default("standard"), }); export const boardSettingsSchema = z.object({ attachmentMode: z.enum(["link", "copy"]).default("link"), }); export const boardSchema = z.object({ id: z.string(), title: z.string(), color: z.string().default("#4a9d7f"), createdAt: z.string(), updatedAt: z.string(), columns: z.array(columnSchema).default([]), cards: z.record(z.string(), cardSchema).default({}), labels: z.array(labelSchema).default([]), settings: boardSettingsSchema.default({}), }); export const appSettingsSchema = z.object({ theme: z.enum(["light", "dark", "system"]).default("system"), dataDirectory: z.string().nullable().default(null), recentBoardIds: z.array(z.string()).default([]), }); ``` **Step 4: Commit** ```bash git add src/types/ src/lib/schemas.ts git commit -m "feat: add board/settings type definitions and Zod validation schemas" ``` --- ## Task 3: Filesystem Persistence Layer **Files:** - Create: `src/lib/storage.ts` - Create: `src/lib/storage.test.ts` **Step 1: Create the storage module** Create `src/lib/storage.ts`: ```typescript import { exists, mkdir, readTextFile, writeTextFile, readDir, remove, copyFile, BaseDirectory, } from "@tauri-apps/plugin-fs"; import { appDataDir, join } from "@tauri-apps/api/path"; import { boardSchema, appSettingsSchema } from "./schemas"; import type { Board, BoardMeta } from "../types/board"; import type { AppSettings } from "../types/settings"; const APP_DIR_NAME = "openpylon"; async function getAppDir(): Promise { const base = await appDataDir(); return await join(base, APP_DIR_NAME); } async function getBoardsDir(): Promise { const appDir = await getAppDir(); return await join(appDir, "boards"); } async function getAttachmentsDir(boardId: string): Promise { const appDir = await getAppDir(); return await join(appDir, "attachments", boardId); } export async function ensureDataDirs(): Promise { const appDir = await getAppDir(); const boardsDir = await getBoardsDir(); const attachDir = await join(appDir, "attachments"); for (const dir of [appDir, boardsDir, attachDir]) { if (!(await exists(dir))) { await mkdir(dir, { recursive: true }); } } } export async function loadSettings(): Promise { const appDir = await getAppDir(); const settingsPath = await join(appDir, "settings.json"); if (!(await exists(settingsPath))) { const defaults: AppSettings = { theme: "system", dataDirectory: null, recentBoardIds: [], }; await writeTextFile(settingsPath, JSON.stringify(defaults, null, 2)); return defaults; } const raw = await readTextFile(settingsPath); return appSettingsSchema.parse(JSON.parse(raw)); } export async function saveSettings(settings: AppSettings): Promise { const appDir = await getAppDir(); const settingsPath = await join(appDir, "settings.json"); await writeTextFile(settingsPath, JSON.stringify(settings, null, 2)); } export async function listBoards(): Promise { const boardsDir = await getBoardsDir(); if (!(await exists(boardsDir))) { return []; } const entries = await readDir(boardsDir); const metas: BoardMeta[] = []; for (const entry of entries) { if (!entry.name?.endsWith(".json") || entry.name.endsWith(".backup.json")) { continue; } try { const filePath = await join(boardsDir, entry.name); const raw = await readTextFile(filePath); const board = boardSchema.parse(JSON.parse(raw)); metas.push({ id: board.id, title: board.title, color: board.color, cardCount: Object.keys(board.cards).length, columnCount: board.columns.length, updatedAt: board.updatedAt, }); } catch { // Skip corrupted files } } return metas.sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); } export async function loadBoard(boardId: string): Promise { const boardsDir = await getBoardsDir(); const filePath = await join(boardsDir, `board-${boardId}.json`); const raw = await readTextFile(filePath); return boardSchema.parse(JSON.parse(raw)); } export async function saveBoard(board: Board): Promise { const boardsDir = await getBoardsDir(); const filePath = await join(boardsDir, `board-${board.id}.json`); const backupPath = await join(boardsDir, `board-${board.id}.backup.json`); // Rotate current to backup before writing new version if (await exists(filePath)) { try { await copyFile(filePath, backupPath); } catch { // Backup failure is non-fatal } } await writeTextFile(filePath, JSON.stringify(board, null, 2)); } export async function deleteBoard(boardId: string): Promise { const boardsDir = await getBoardsDir(); const filePath = await join(boardsDir, `board-${boardId}.json`); const backupPath = await join(boardsDir, `board-${boardId}.backup.json`); if (await exists(filePath)) await remove(filePath); if (await exists(backupPath)) await remove(backupPath); // Remove attachments dir too const attachDir = await getAttachmentsDir(boardId); if (await exists(attachDir)) await remove(attachDir, { recursive: true }); } export async function searchAllBoards( query: string ): Promise> { const boardsDir = await getBoardsDir(); const entries = await readDir(boardsDir); const results: Array<{ boardId: string; boardTitle: string; card: { id: string; title: string } }> = []; const lowerQuery = query.toLowerCase(); for (const entry of entries) { if (!entry.name?.endsWith(".json") || entry.name.endsWith(".backup.json")) { continue; } try { const filePath = await join(boardsDir, entry.name); const raw = await readTextFile(filePath); const board = boardSchema.parse(JSON.parse(raw)); for (const card of Object.values(board.cards)) { if ( card.title.toLowerCase().includes(lowerQuery) || card.description.toLowerCase().includes(lowerQuery) ) { results.push({ boardId: board.id, boardTitle: board.title, card: { id: card.id, title: card.title }, }); } } } catch { // Skip corrupted files } } return results; } export async function restoreFromBackup(boardId: string): Promise { const boardsDir = await getBoardsDir(); const backupPath = await join(boardsDir, `board-${boardId}.backup.json`); if (!(await exists(backupPath))) return null; const raw = await readTextFile(backupPath); return boardSchema.parse(JSON.parse(raw)); } ``` **Step 2: Commit** ```bash git add src/lib/storage.ts git commit -m "feat: add filesystem persistence layer for boards and settings" ``` --- ## Task 4: Zustand Stores **Files:** - Create: `src/stores/app-store.ts` - Create: `src/stores/board-store.ts` **Step 1: Create the app store** Create `src/stores/app-store.ts`: ```typescript import { create } from "zustand"; import type { AppSettings } from "../types/settings"; import type { BoardMeta } from "../types/board"; import { loadSettings, saveSettings, listBoards } from "../lib/storage"; type View = { type: "board-list" } | { type: "board"; boardId: string }; interface AppState { settings: AppSettings; boards: BoardMeta[]; view: View; initialized: boolean; init: () => Promise; setTheme: (theme: AppSettings["theme"]) => void; setView: (view: View) => void; refreshBoards: () => Promise; addRecentBoard: (boardId: string) => void; } export const useAppStore = create((set, get) => ({ settings: { theme: "system", dataDirectory: null, recentBoardIds: [] }, boards: [], view: { type: "board-list" }, initialized: false, init: async () => { const settings = await loadSettings(); const boards = await listBoards(); set({ settings, boards, initialized: true }); applyTheme(settings.theme); }, setTheme: (theme) => { const settings = { ...get().settings, theme }; set({ settings }); saveSettings(settings); applyTheme(theme); }, setView: (view) => set({ view }), refreshBoards: async () => { const boards = await listBoards(); set({ boards }); }, addRecentBoard: (boardId) => { const settings = get().settings; const recent = [boardId, ...settings.recentBoardIds.filter((id) => id !== boardId)].slice(0, 10); const updated = { ...settings, recentBoardIds: recent }; set({ settings: updated }); saveSettings(updated); }, })); function applyTheme(theme: AppSettings["theme"]): void { const root = document.documentElement; if (theme === "system") { const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; root.classList.toggle("dark", prefersDark); } else { root.classList.toggle("dark", theme === "dark"); } } ``` **Step 2: Create the board store with undo/redo** Create `src/stores/board-store.ts`: ```typescript import { create } from "zustand"; import { temporal } from "zundo"; import { ulid } from "ulid"; import type { Board, Card, Column, Label, ChecklistItem, Attachment, ColumnWidth } from "../types/board"; import { saveBoard, loadBoard } from "../lib/storage"; interface BoardState { board: Board | null; saving: boolean; lastSaved: number | null; } interface BoardActions { // Board lifecycle openBoard: (boardId: string) => Promise; closeBoard: () => void; // Column actions addColumn: (title: string) => void; updateColumnTitle: (columnId: string, title: string) => void; deleteColumn: (columnId: string) => void; moveColumn: (fromIndex: number, toIndex: number) => void; setColumnWidth: (columnId: string, width: ColumnWidth) => void; // Card actions addCard: (columnId: string, title: string) => void; updateCard: (cardId: string, updates: Partial) => void; deleteCard: (cardId: string) => void; moveCard: (cardId: string, fromColumnId: string, toColumnId: string, toIndex: number) => void; // Label actions addLabel: (name: string, color: string) => void; updateLabel: (labelId: string, updates: Partial