Detailed step-by-step plan covering scaffold, types, storage, stores, UI components, drag-and-drop, command palette, import/export, animations, accessibility, and production build.
1892 lines
56 KiB
Markdown
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"
|
|
>
|
|
← Boards
|
|
</button>
|
|
)}
|
|
|
|
{isBoard && !editing && (
|
|
<h1
|
|
className="font-heading text-lg cursor-text"
|
|
onClick={() => {
|
|
setTitle(board.title);
|
|
setEditing(true);
|
|
}}
|
|
>
|
|
{board.title}
|
|
</h1>
|
|
)}
|
|
|
|
{isBoard && editing && (
|
|
<input
|
|
ref={inputRef}
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
onBlur={handleTitleSave}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleTitleSave();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
className="font-heading text-lg bg-transparent border-b border-pylon-accent outline-none"
|
|
/>
|
|
)}
|
|
|
|
{!isBoard && (
|
|
<h1 className="font-heading text-lg">OpenPylon</h1>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{saving && (
|
|
<span className="text-xs font-mono text-pylon-text-secondary">Saving...</span>
|
|
)}
|
|
{!saving && lastSaved && (
|
|
<span className="text-xs font-mono text-pylon-text-secondary">Saved</span>
|
|
)}
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="font-mono text-xs"
|
|
onClick={() => {
|
|
// Will be wired to command palette
|
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
|
|
}}
|
|
>
|
|
⌘K
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Command palette</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
// Will open settings dialog
|
|
}}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Settings</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Create the app shell**
|
|
|
|
Create `src/components/layout/AppShell.tsx`:
|
|
```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)
|