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

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"
          >
            &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:

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 palette
  • Ctrl+N → new board dialog
  • Ctrl+ZuseBoardStore.temporal.getState().undo()
  • Ctrl+Shift+ZuseBoardStore.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

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

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-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:

: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)