From d167d0aa0b4d82867ef531d8e9fe984de724c3c6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 15 Feb 2026 18:15:14 +0200 Subject: [PATCH] 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-openpylon-implementation.md | 1891 +++++++++++++++++ 1 file changed, 1891 insertions(+) create mode 100644 docs/plans/2026-02-15-openpylon-implementation.md diff --git a/docs/plans/2026-02-15-openpylon-implementation.md b/docs/plans/2026-02-15-openpylon-implementation.md new file mode 100644 index 0000000..c4fe890 --- /dev/null +++ b/docs/plans/2026-02-15-openpylon-implementation.md @@ -0,0 +1,1891 @@ +# 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