From d59d762dd5a8c8fc0314c037900b1f7e52e7bc64 Mon Sep 17 00:00:00 2001 From: Csaba Toth Date: Wed, 25 Mar 2026 16:48:44 +0100 Subject: [PATCH 01/49] Add canvas workspace design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design spec for the /canvas route — an infinite canvas workspace that evolves PinLaunch from a landing page generator into a full design tool. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-25-canvas-workspace-design.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-25-canvas-workspace-design.md diff --git a/docs/superpowers/specs/2026-03-25-canvas-workspace-design.md b/docs/superpowers/specs/2026-03-25-canvas-workspace-design.md new file mode 100644 index 0000000..4f924d3 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-canvas-workspace-design.md @@ -0,0 +1,258 @@ +# Canvas Workspace — Design Spec + +**Date:** 2026-03-25 +**Status:** Approved + +## Overview + +PinLaunch evolves from a landing page generator into a full design tool. The `/canvas` route introduces an infinite canvas workspace where users collect references, generate pages via AI, iterate on designs, and export a complete application. The canvas is the central workspace; the sidebar is the interaction surface. + +## Node Types + +### Artboard (generated pages) +- Fixed viewport sizes: desktop (1440x900), tablet (768x1024), mobile (375x812) +- Rendered via iframe pointing to `/api/preview/` routes +- Header bar: name, viewport badge, exclude-from-export toggle, "Edit" button +- "Edit" button switches the Chat tab to this artboard's context for AI refinement +- Not freely resizable — viewport preset switching only +- Draggable on canvas +- **Pointer event handling:** A transparent overlay div captures drag/pan events by default. When the user clicks "Edit" (or double-clicks the artboard), it enters interactive mode — the overlay is removed and pointer events pass through to the iframe. Clicking outside or pressing Escape exits interactive mode. +- **Virtualization:** Only artboards whose bounding box intersects the current viewport render an iframe. Off-screen artboards render a static thumbnail placeholder (captured on last content change). + +### Image +- Drag-and-drop from filesystem or URL +- Freely resizable with optional aspect ratio lock (Shift+drag) +- Uploaded via `POST /api/uploads` (multipart form data), stored in `data/uploads/` +- Canvas state references relative path +- If the referenced file is missing, displays a "missing image" placeholder + +### Document +- Rendered markdown by default +- Double-click → inline markdown editor +- Freely movable and resizable +- Semi-transparent surface background + +### Shape (wireframe primitives) +- Types: `rectangle`, `circle`, `rounded-rect`, `line` +- Rendered with roughjs **SVG mode** (inline SVG elements in the DOM) +- Central text label on each (DOM element, positioned over the SVG) +- Color and border customizable via inspector +- Freely movable and resizable + +### UI Widget (wireframe components) +- Predefined set: `button`, `cta`, `input`, `dropdown`, `navbar`, `card`, `hero`, `footer`, `checkbox`, `toggle` +- Rendered with roughjs **SVG mode** with recognizable form (e.g., button = rounded rect + centered text, dropdown = rect + arrow) +- Editable label +- Freely movable and resizable + +## Canvas Architecture + +### Layer stack (bottom to top) +1. **Grid layer** — infinite background grid (dot pattern), CSS-based, scales with zoom +2. **Node layer** — all nodes rendered as React DOM elements (SVG for shapes/widgets via roughjs SVG mode, HTML for artboards/images/documents), absolutely positioned in world-space + +Using roughjs SVG mode eliminates the need for a separate `` element. All nodes live in the same DOM layer, simplifying zoom/pan synchronization. + +### Pan/Zoom +- `react-zoom-pan-pinch` wraps the node layer in a `TransformWrapper` +- Grid layer uses CSS background-image that scales with the transform + +### Coordinate system +- Every node stores `{x, y, width, height}` in canvas world-space +- Pan/zoom transform handled by library + +### Node ordering +- Each node has a `zIndex` field (integer, default 0) +- Higher zIndex renders on top +- Context menu: "Bring to Front" / "Send to Back" +- New nodes get `zIndex = max(existing) + 1` + +### State model +```typescript +type NodeType = 'artboard' | 'image' | 'document' | 'shape' | 'widget' + +type CanvasNode = { + id: string + type: NodeType + x: number + y: number + width: number + height: number + zIndex: number + excludeFromExport?: boolean // artboards only + data: ArtboardNodeData | ImageNodeData | DocNodeData | ShapeNodeData | WidgetNodeData +} + +type ArtboardNodeData = { + name: string + siteDir: string // e.g., "site-1711270400000" + viewport: 'desktop' | 'tablet' | 'mobile' + provider: 'gemini' | 'claude' + sessionId?: string + thumbnailUrl?: string // cached screenshot for off-screen rendering +} + +type ImageNodeData = { + src: string // relative path in data/uploads/ + alt?: string +} + +type DocNodeData = { + markdown: string + title?: string +} + +type ShapeNodeData = { + shapeType: 'rectangle' | 'circle' | 'rounded-rect' | 'line' + label: string + fillColor?: string + strokeColor?: string +} + +type WidgetNodeData = { + widgetType: 'button' | 'cta' | 'input' | 'dropdown' | 'navbar' | 'card' | 'hero' | 'footer' | 'checkbox' | 'toggle' + label: string + fillColor?: string + strokeColor?: string +} + +type CanvasState = { + nodes: CanvasNode[] + viewport: { x: number; y: number; zoom: number } +} +``` + +### Persistence +- Stored in SQLite `canvas_state` table (JSON column), consistent with the existing data layer +- Schema: `canvas_state (id INTEGER PRIMARY KEY, project_id INTEGER REFERENCES projects(id), state TEXT, updated_at TEXT)` +- One canvas per project (uses existing `projects` table) +- Initial implementation uses a single auto-created default project. Multi-project canvas support will be added later. +- Auto-saved on every change (debounced ~500ms) +- `beforeunload` as best-effort fallback (unreliable on mobile) +- API route: `GET/PUT /api/canvas?projectId=...` + +## Canvas Interactions + +### Selection +- Click → select node (blue border + resize handles) +- Shift+click → multi-select +- Drag on empty area → lasso selection rectangle +- Escape → deselect + +### Move and resize +- Drag selected node → move +- Corner handles → resize (disabled for artboards — viewport preset only) +- Shift+drag on shapes/images → aspect ratio lock + +### Navigation +- Scroll / trackpad → pan +- Ctrl+scroll / pinch → zoom (10%–400%) +- Double-click empty area → zoom to fit all nodes +- Minimap in bottom-right corner: shows bounding-box outlines of all nodes, viewport rectangle indicator, click to navigate + +### Creating nodes +- Top toolbar: shape tools, widget palette, "Add Image", "Add Document" +- Shape tool selected → drag on canvas → draw shape +- Image: toolbar button or drag-and-drop from filesystem +- New artboards: only created through generation (sidebar Setup/Chat) + +### Deleting nodes +- Delete/Backspace on selected nodes +- Right-click → context menu → Delete + +### Undo/Redo +- Ctrl+Z / Ctrl+Shift+Z +- In-memory history stack (max ~50 steps) +- Stores full state snapshots (canvas node count is small enough for this approach) + +### Empty canvas +- First visit shows centered welcome message: "Start by adding pins and generating your first page in the Setup tab" +- Arrow pointing to the sidebar Setup tab + +## Sidebar + +Three modes via tabs: + +### Tab 1 — Setup +- Embeds existing components: PinBoard, GitHubPanel, PresetsPanel, GeneratePanel +- These components need interface adaptation: `GeneratePanel.onSiteReady` callback will create a new artboard node instead of setting session state. `RefinementChat` props (`siteDir`, `sessionId`, `provider`) will come from the selected artboard node. +- "Generate" button triggers generation → result appears as new artboard on canvas + +### Tab 2 — Chat +- Evolution of RefinementChat +- Context: entire canvas state or selected nodes +- When "Edit in chat" is clicked on an artboard, this tab activates with that artboard's context +- Prompt → AI iterates on selected artboard or generates new one +- ClaudeTerminal and ViteSetupTerminal embedded here + +### Tab 3 — Inspector +- Appears when a node is selected +- Artboard: preview URL, exclude toggle, viewport preset, "Edit in chat" button +- Image: dimensions, alt text +- Document: markdown editor +- Shape/Widget: label, colors, size + +**Width:** 380px, collapsible for full canvas view. + +## Export and Sync + +### Incremental sync +- Each artboard maps to `output/site-{siteDir}/` +- When artboard content changes (AI iteration), files update immediately +- No manual per-artboard export needed + +### Exclude from export +- Toggle on artboard header and in inspector +- Flag: `canvasNode.excludeFromExport = true` + +### Full app export (future phase) +> **Note:** Full app export (merging multiple artboards into a routed application) is out of scope for the initial implementation. The incremental sync per artboard and exclude toggles are the MVP export mechanism. Full app export will be designed separately once the canvas workflow is validated. + +## Routing and File Structure + +### Routes +- `/` — Redirects to `/canvas` +- `/canvas` — Main workspace + +### New file structure +``` +src/app/canvas/ + page.tsx — Canvas page (client component) + +src/components/canvas/ + Canvas.tsx — Main canvas (pan/zoom + node layer) + CanvasToolbar.tsx — Top toolbar (shape tools, add image, etc.) + CanvasNode.tsx — Dispatcher: node type → renderer + ArtboardNode.tsx — Iframe artboard with overlay + interactive mode + ImageNode.tsx — Image node + DocumentNode.tsx — Markdown document node + ShapeNode.tsx — Wireframe shape (roughjs SVG) + WidgetNode.tsx — UI widget (roughjs SVG) + Minimap.tsx — Bottom-right minimap (bounding-box view) + Sidebar.tsx — Refactored sidebar (Setup/Chat/Inspector tabs) + InspectorPanel.tsx — Node property inspector + +src/lib/canvas-state.ts — State management (CRUD, undo/redo, persistence) + +src/app/api/canvas/ + route.ts — GET/PUT canvas state (SQLite) +src/app/api/uploads/ + route.ts — POST image upload (multipart → data/uploads/) +``` + +### Reused components (with interface adaptation) +- PinBoard, GitHubPanel, PresetsPanel, GeneratePanel — embedded in Sidebar Setup tab +- ClaudeTerminal, ViteSetupTerminal — embedded in Sidebar Chat tab +- RefinementChat — evolved into the Chat tab (props sourced from selected artboard node) + +### Retired components +- PreviewFrame — replaced by ArtboardNode (iframe directly on canvas) + +## Dependencies + +### New +- `react-zoom-pan-pinch` — pan/zoom for infinite canvas +- `roughjs` — hand-drawn wireframe rendering (SVG mode) + +### Existing (unchanged) +- All current dependencies remain From dfc7f53b80419a77e2a10be6a0a81df75ff4330f Mon Sep 17 00:00:00 2001 From: Csaba Toth Date: Wed, 25 Mar 2026 17:13:05 +0100 Subject: [PATCH 02/49] Add canvas workspace implementation plan 13-task implementation plan covering: types/state management, DB/API routes, canvas core with pan/zoom, shape/widget/image/document/artboard nodes, toolbar, sidebar, inspector, minimap, and integration tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-25-canvas-workspace.md | 2060 +++++++++++++++++ 1 file changed, 2060 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-25-canvas-workspace.md diff --git a/docs/superpowers/plans/2026-03-25-canvas-workspace.md b/docs/superpowers/plans/2026-03-25-canvas-workspace.md new file mode 100644 index 0000000..357097d --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-canvas-workspace.md @@ -0,0 +1,2060 @@ +# Canvas Workspace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an infinite canvas workspace at `/canvas` that serves as the central design tool — displaying generated pages as artboards, images, documents, and wireframe shapes/widgets. + +**Architecture:** Two-layer canvas (CSS grid background + DOM node layer) using `react-zoom-pan-pinch` for pan/zoom. All nodes are React DOM elements — shapes/widgets rendered as inline SVG via roughjs SVG mode, artboards as iframes. State managed in a custom React hook with undo/redo, persisted to SQLite via API route. + +**Tech Stack:** Next.js 15, React 19, Tailwind CSS v4, react-zoom-pan-pinch, roughjs, better-sqlite3 + +**Spec:** `docs/superpowers/specs/2026-03-25-canvas-workspace-design.md` + +--- + +## File Map + +### New files +| File | Responsibility | +|------|---------------| +| `src/lib/canvas-types.ts` | TypeScript types for CanvasNode, CanvasState, all node data types | +| `src/lib/canvas-state.ts` | State management: CRUD operations, undo/redo history, auto-save logic | +| `src/lib/canvas-state.test.ts` | Tests for canvas state management | +| `src/app/api/canvas/route.ts` | GET/PUT canvas state from/to SQLite | +| `src/app/api/uploads/route.ts` | POST image upload (multipart → data/uploads/) | +| `src/app/canvas/page.tsx` | Canvas page — client component, mounts Canvas + Sidebar | +| `src/components/canvas/Canvas.tsx` | Main canvas: pan/zoom wrapper, grid background, node layer | +| `src/components/canvas/CanvasToolbar.tsx` | Top toolbar: shape tools, widget palette, add image/doc | +| `src/components/canvas/CanvasNode.tsx` | Dispatcher: routes node.type to correct renderer | +| `src/components/canvas/ShapeNode.tsx` | Wireframe shape rendering (roughjs SVG) | +| `src/components/canvas/WidgetNode.tsx` | UI widget rendering (roughjs SVG) | +| `src/components/canvas/ImageNode.tsx` | Image node with missing-file placeholder | +| `src/components/canvas/DocumentNode.tsx` | Markdown document with inline editing | +| `src/components/canvas/ArtboardNode.tsx` | Iframe artboard with overlay + interactive mode | +| `src/components/canvas/Sidebar.tsx` | Tabbed sidebar: Setup / Chat / Inspector | +| `src/components/canvas/InspectorPanel.tsx` | Node property inspector per type | +| `src/components/canvas/Minimap.tsx` | Bottom-right minimap navigation | + +### Modified files +| File | Change | +|------|--------| +| `src/lib/db.ts` | Add `canvas_state` table creation | +| `src/app/page.tsx` | Redirect to `/canvas` | +| `src/components/GeneratePanel.tsx` | Make `onSiteReady` callback signature flexible for canvas use | +| `src/components/Header.tsx` | Update navigation for canvas route | + +--- + +## Task 1: Types and State Management + +**Files:** +- Create: `src/lib/canvas-types.ts` +- Create: `src/lib/canvas-state.ts` +- Create: `src/lib/canvas-state.test.ts` + +### Step 1.1 — Write canvas types + +- [ ] Create `src/lib/canvas-types.ts` with all types from the spec: + +```typescript +export type NodeType = 'artboard' | 'image' | 'document' | 'shape' | 'widget' + +export type ShapeType = 'rectangle' | 'circle' | 'rounded-rect' | 'line' + +export type WidgetType = 'button' | 'cta' | 'input' | 'dropdown' | 'navbar' | 'card' | 'hero' | 'footer' | 'checkbox' | 'toggle' + +export type Viewport = 'desktop' | 'tablet' | 'mobile' + +export const VIEWPORT_SIZES: Record = { + desktop: { width: 1440, height: 900 }, + tablet: { width: 768, height: 1024 }, + mobile: { width: 375, height: 812 }, +} + +export type ArtboardNodeData = { + name: string + siteDir: string + viewport: Viewport + provider: 'gemini' | 'claude' + sessionId?: string + thumbnailUrl?: string +} + +export type ImageNodeData = { + src: string + alt?: string +} + +export type DocNodeData = { + markdown: string + title?: string +} + +export type ShapeNodeData = { + shapeType: ShapeType + label: string + fillColor?: string + strokeColor?: string +} + +export type WidgetNodeData = { + widgetType: WidgetType + label: string + fillColor?: string + strokeColor?: string +} + +export type CanvasNode = { + id: string + type: NodeType + x: number + y: number + width: number + height: number + zIndex: number + excludeFromExport?: boolean + data: ArtboardNodeData | ImageNodeData | DocNodeData | ShapeNodeData | WidgetNodeData +} + +export type CanvasState = { + nodes: CanvasNode[] + viewport: { x: number; y: number; zoom: number } +} + +export function createEmptyState(): CanvasState { + return { nodes: [], viewport: { x: 0, y: 0, zoom: 1 } } +} +``` + +- [ ] Commit: `git add src/lib/canvas-types.ts && git commit -m "feat(canvas): add TypeScript types for canvas state model"` + +### Step 1.2 — Write failing tests for canvas state operations + +- [ ] Create `src/lib/canvas-state.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest' +import { + addNode, removeNode, updateNode, moveNode, + bringToFront, sendToBack, nextZIndex, + createUndoRedoManager, +} from './canvas-state' +import { CanvasNode, CanvasState, createEmptyState } from './canvas-types' + +const makeShape = (id: string, x = 0, y = 0): CanvasNode => ({ + id, type: 'shape', x, y, width: 100, height: 100, zIndex: 0, + data: { shapeType: 'rectangle', label: 'test' }, +}) + +describe('addNode', () => { + it('adds a node to empty state', () => { + const state = createEmptyState() + const node = makeShape('s1') + const next = addNode(state, node) + expect(next.nodes).toHaveLength(1) + expect(next.nodes[0].id).toBe('s1') + }) + + it('assigns next zIndex automatically', () => { + let state = createEmptyState() + state = addNode(state, makeShape('s1')) + state = addNode(state, makeShape('s2')) + expect(state.nodes[1].zIndex).toBe(1) + }) +}) + +describe('removeNode', () => { + it('removes a node by id', () => { + let state = createEmptyState() + state = addNode(state, makeShape('s1')) + state = addNode(state, makeShape('s2')) + state = removeNode(state, 's1') + expect(state.nodes).toHaveLength(1) + expect(state.nodes[0].id).toBe('s2') + }) + + it('returns same state if id not found', () => { + const state = addNode(createEmptyState(), makeShape('s1')) + const next = removeNode(state, 'nonexistent') + expect(next).toBe(state) + }) +}) + +describe('updateNode', () => { + it('updates node properties', () => { + let state = addNode(createEmptyState(), makeShape('s1', 0, 0)) + state = updateNode(state, 's1', { x: 50, y: 100 }) + expect(state.nodes[0].x).toBe(50) + expect(state.nodes[0].y).toBe(100) + }) +}) + +describe('moveNode', () => { + it('updates x and y', () => { + let state = addNode(createEmptyState(), makeShape('s1', 10, 20)) + state = moveNode(state, 's1', 50, 60) + expect(state.nodes[0].x).toBe(50) + expect(state.nodes[0].y).toBe(60) + }) +}) + +describe('bringToFront / sendToBack', () => { + it('bringToFront sets highest zIndex', () => { + let state = createEmptyState() + state = addNode(state, makeShape('s1')) + state = addNode(state, makeShape('s2')) + state = addNode(state, makeShape('s3')) + state = bringToFront(state, 's1') + const node = state.nodes.find(n => n.id === 's1')! + expect(node.zIndex).toBe(3) + }) + + it('sendToBack sets zIndex 0 and shifts others up', () => { + let state = createEmptyState() + state = addNode(state, makeShape('s1')) + state = addNode(state, makeShape('s2')) + state = addNode(state, makeShape('s3')) + state = sendToBack(state, 's3') + const node = state.nodes.find(n => n.id === 's3')! + expect(node.zIndex).toBe(0) + }) +}) + +describe('undo/redo', () => { + it('undo reverts to previous state', () => { + const mgr = createUndoRedoManager() + const s1 = addNode(createEmptyState(), makeShape('s1')) + mgr.push(s1) + const s2 = addNode(s1, makeShape('s2')) + mgr.push(s2) + const undone = mgr.undo() + expect(undone?.nodes).toHaveLength(1) + }) + + it('redo restores undone state', () => { + const mgr = createUndoRedoManager() + const s1 = addNode(createEmptyState(), makeShape('s1')) + mgr.push(s1) + const s2 = addNode(s1, makeShape('s2')) + mgr.push(s2) + mgr.undo() + const redone = mgr.redo() + expect(redone?.nodes).toHaveLength(2) + }) + + it('push after undo clears redo stack', () => { + const mgr = createUndoRedoManager() + mgr.push(addNode(createEmptyState(), makeShape('s1'))) + mgr.push(addNode(createEmptyState(), makeShape('s2'))) + mgr.undo() + mgr.push(addNode(createEmptyState(), makeShape('s3'))) + expect(mgr.redo()).toBeNull() + }) + + it('respects max history size', () => { + const mgr = createUndoRedoManager(3) + for (let i = 0; i < 5; i++) { + mgr.push(addNode(createEmptyState(), makeShape(`s${i}`))) + } + let count = 0 + while (mgr.undo()) count++ + expect(count).toBe(2) // 3 states = 2 undos + }) +}) +``` + +- [ ] Run tests to verify they fail: + +```bash +npx vitest run src/lib/canvas-state.test.ts +``` + +Expected: FAIL — module `./canvas-state` has no exports. + +### Step 1.3 — Implement canvas state operations + +- [ ] Create `src/lib/canvas-state.ts`: + +```typescript +import { CanvasNode, CanvasState } from './canvas-types' + +export function nextZIndex(state: CanvasState): number { + if (state.nodes.length === 0) return 0 + return Math.max(...state.nodes.map(n => n.zIndex)) + 1 +} + +export function addNode(state: CanvasState, node: CanvasNode): CanvasState { + const withZ = { ...node, zIndex: nextZIndex(state) } + return { ...state, nodes: [...state.nodes, withZ] } +} + +export function removeNode(state: CanvasState, id: string): CanvasState { + const filtered = state.nodes.filter(n => n.id !== id) + if (filtered.length === state.nodes.length) return state + return { ...state, nodes: filtered } +} + +export function updateNode(state: CanvasState, id: string, updates: Partial): CanvasState { + return { + ...state, + nodes: state.nodes.map(n => n.id === id ? { ...n, ...updates } : n), + } +} + +export function moveNode(state: CanvasState, id: string, x: number, y: number): CanvasState { + return updateNode(state, id, { x, y }) +} + +export function bringToFront(state: CanvasState, id: string): CanvasState { + return updateNode(state, id, { zIndex: nextZIndex(state) }) +} + +export function sendToBack(state: CanvasState, id: string): CanvasState { + const node = state.nodes.find(n => n.id === id) + if (!node) return state + return { + ...state, + nodes: state.nodes.map(n => { + if (n.id === id) return { ...n, zIndex: 0 } + if (n.zIndex < node.zIndex) return { ...n, zIndex: n.zIndex + 1 } + return n + }), + } +} + +export type UndoRedoManager = { + push: (state: CanvasState) => void + undo: () => CanvasState | null + redo: () => CanvasState | null +} + +export function createUndoRedoManager(maxSize = 50): UndoRedoManager { + const past: CanvasState[] = [] + const future: CanvasState[] = [] + + return { + push(state: CanvasState) { + past.push(state) + future.length = 0 + if (past.length > maxSize) past.shift() + }, + undo(): CanvasState | null { + if (past.length <= 1) return null + const current = past.pop()! + future.push(current) + return past[past.length - 1] + }, + redo(): CanvasState | null { + if (future.length === 0) return null + const state = future.pop()! + past.push(state) + return state + }, + } +} +``` + +- [ ] Run tests: + +```bash +npx vitest run src/lib/canvas-state.test.ts +``` + +Expected: ALL PASS + +- [ ] Commit: `git add src/lib/canvas-state.ts src/lib/canvas-state.test.ts && git commit -m "feat(canvas): add state management with CRUD and undo/redo"` + +--- + +## Task 2: Database and API Routes + +**Files:** +- Modify: `src/lib/db.ts` +- Create: `src/app/api/canvas/route.ts` +- Create: `src/app/api/uploads/route.ts` + +### Step 2.1 — Add canvas_state table to database + +- [ ] In `src/lib/db.ts`, add the canvas_state table creation after the existing `projects` table creation: + +```sql +CREATE TABLE IF NOT EXISTS canvas_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER REFERENCES projects(id), + state TEXT NOT NULL DEFAULT '{"nodes":[],"viewport":{"x":0,"y":0,"zoom":1}}', + updated_at TEXT DEFAULT (datetime('now')) +) +``` + +- [ ] Run `npm run build` to verify no compilation errors. +- [ ] Commit: `git add src/lib/db.ts && git commit -m "feat(canvas): add canvas_state table to SQLite schema"` + +### Step 2.2 — Create canvas API route + +- [ ] Create `src/app/api/canvas/route.ts`: + +```typescript +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' + +export async function GET(req: NextRequest) { + const projectId = req.nextUrl.searchParams.get('projectId') + const db = getDb() + + if (!projectId) { + // Auto-create or fetch default project + let project = db.prepare('SELECT id FROM projects ORDER BY id LIMIT 1').get() as { id: number } | undefined + if (!project) { + const result = db.prepare( + 'INSERT INTO projects (name, site_dir, provider, framework) VALUES (?, ?, ?, ?)' + ).run('Default Project', `project-${Date.now()}`, 'gemini', 'html') + project = { id: result.lastInsertRowid as number } + } + const row = db.prepare('SELECT state FROM canvas_state WHERE project_id = ?').get(project.id) as { state: string } | undefined + if (!row) { + const defaultState = JSON.stringify({ nodes: [], viewport: { x: 0, y: 0, zoom: 1 } }) + db.prepare('INSERT INTO canvas_state (project_id, state) VALUES (?, ?)').run(project.id, defaultState) + return NextResponse.json({ projectId: project.id, state: JSON.parse(defaultState) }) + } + return NextResponse.json({ projectId: project.id, state: JSON.parse(row.state) }) + } + + const row = db.prepare('SELECT state FROM canvas_state WHERE project_id = ?').get(Number(projectId)) as { state: string } | undefined + if (!row) { + return NextResponse.json({ error: 'Canvas not found' }, { status: 404 }) + } + return NextResponse.json({ projectId: Number(projectId), state: JSON.parse(row.state) }) +} + +export async function PUT(req: NextRequest) { + const { projectId, state } = await req.json() + const db = getDb() + + const existing = db.prepare('SELECT id FROM canvas_state WHERE project_id = ?').get(projectId) + if (existing) { + db.prepare('UPDATE canvas_state SET state = ?, updated_at = datetime(?) WHERE project_id = ?') + .run(JSON.stringify(state), new Date().toISOString(), projectId) + } else { + db.prepare('INSERT INTO canvas_state (project_id, state) VALUES (?, ?)') + .run(projectId, JSON.stringify(state)) + } + + return NextResponse.json({ ok: true }) +} + +// POST handler for navigator.sendBeacon (which always sends POST) +export async function POST(req: NextRequest) { + return PUT(req) +} +``` + +- [ ] Run `npm run build` to verify compilation. +- [ ] Commit: `git add src/app/api/canvas/route.ts && git commit -m "feat(canvas): add GET/PUT API route for canvas state"` + +### Step 2.3 — Create uploads API route + +- [ ] Create `src/app/api/uploads/route.ts`: + +```typescript +import { NextRequest, NextResponse } from 'next/server' +import { writeFile, mkdir } from 'fs/promises' +import path from 'path' + +const UPLOADS_DIR = path.join(process.cwd(), 'data', 'uploads') + +export async function POST(req: NextRequest) { + const formData = await req.formData() + const file = formData.get('file') as File | null + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } + + await mkdir(UPLOADS_DIR, { recursive: true }) + + const timestamp = Date.now() + const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_') + const filename = `${timestamp}-${safeName}` + const filepath = path.join(UPLOADS_DIR, filename) + + const buffer = Buffer.from(await file.arrayBuffer()) + await writeFile(filepath, buffer) + + return NextResponse.json({ path: `uploads/${filename}` }) +} +``` + +- [ ] Run `npm run build` to verify compilation. +- [ ] Commit: `git add src/app/api/uploads/route.ts && git commit -m "feat(canvas): add image upload API route"` + +--- + +## Task 3: Canvas Core — Pan/Zoom and Grid + +**Files:** +- Create: `src/app/canvas/page.tsx` +- Create: `src/components/canvas/Canvas.tsx` + +### Step 3.1 — Install dependencies + +- [ ] Install: + +```bash +cd /Users/csacsi/DEV/PinLaunch && npm install react-zoom-pan-pinch roughjs +``` + +- [ ] Commit: `git add package.json package-lock.json && git commit -m "feat(canvas): add react-zoom-pan-pinch and roughjs dependencies"` + +### Step 3.2 — Create Canvas component with pan/zoom and grid + +- [ ] Create `src/components/canvas/Canvas.tsx`: + +```typescript +'use client' + +import { useRef, useCallback, useState, ReactNode } from 'react' +import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch' + +interface CanvasProps { + children: ReactNode + onTransformChange?: (x: number, y: number, zoom: number) => void +} + +export default function Canvas({ children, onTransformChange }: CanvasProps) { + const transformRef = useRef(null) + const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }) + + const handleTransform = useCallback((_: unknown, state: { positionX: number; positionY: number; scale: number }) => { + setTransform({ x: state.positionX, y: state.positionY, scale: state.scale }) + onTransformChange?.(state.positionX, state.positionY, state.scale) + }, [onTransformChange]) + + // Expose current zoom scale for child components (drag handling) + const getZoom = useCallback(() => transform.scale, [transform.scale]) + + // Grid scales with zoom: backgroundSize and backgroundPosition track the transform + const gridSize = 24 * transform.scale + const gridOffsetX = transform.x % gridSize + const gridOffsetY = transform.y % gridSize + + return ( +
+ {/* Grid background — scales and pans with canvas */} +
+ + + +
+ {children} +
+
+
+
+ ) +} +``` + +- [ ] Commit: `git add src/components/canvas/Canvas.tsx && git commit -m "feat(canvas): add Canvas component with pan/zoom and dot grid"` + +### Step 3.3 — Create canvas page + +- [ ] Create `src/app/canvas/page.tsx`: + +```typescript +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import Canvas from '@/components/canvas/Canvas' +import { CanvasState, CanvasNode, createEmptyState } from '@/lib/canvas-types' +import { addNode, removeNode, updateNode, moveNode, bringToFront, sendToBack, createUndoRedoManager } from '@/lib/canvas-state' + +export default function CanvasPage() { + const [state, setState] = useState(createEmptyState()) + const [projectId, setProjectId] = useState(null) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const undoMgr = useRef(createUndoRedoManager()) + const saveTimer = useRef>() + + // Load canvas state on mount + useEffect(() => { + fetch('/api/canvas') + .then(r => r.json()) + .then(data => { + setProjectId(data.projectId) + setState(data.state) + undoMgr.current.push(data.state) + }) + }, []) + + // Auto-save (debounced 500ms) + const saveState = useCallback((newState: CanvasState) => { + if (saveTimer.current) clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => { + if (projectId === null) return + fetch('/api/canvas', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId, state: newState }), + }) + }, 500) + }, [projectId]) + + // State update with undo tracking and auto-save + const pushState = useCallback((newState: CanvasState) => { + setState(newState) + undoMgr.current.push(newState) + saveState(newState) + }, [saveState]) + + const handleUndo = useCallback(() => { + const prev = undoMgr.current.undo() + if (prev) { setState(prev); saveState(prev) } + }, [saveState]) + + const handleRedo = useCallback(() => { + const next = undoMgr.current.redo() + if (next) { setState(next); saveState(next) } + }, [saveState]) + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'z' && (e.metaKey || e.ctrlKey) && e.shiftKey) { + e.preventDefault(); handleRedo() + } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); handleUndo() + } else if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.size > 0) { + e.preventDefault() + let s = state + for (const id of selectedIds) s = removeNode(s, id) + pushState(s) + setSelectedIds(new Set()) + } else if (e.key === 'Escape') { + setSelectedIds(new Set()) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [state, selectedIds, pushState, handleUndo, handleRedo]) + + // beforeunload save + useEffect(() => { + const handler = () => { + if (projectId === null) return + const blob = new Blob([JSON.stringify({ projectId, state })], { type: 'application/json' }) + navigator.sendBeacon('/api/canvas', blob) + } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [projectId, state]) + + return ( +
+ {/* Sidebar placeholder — Task 7 */} +
+
Sidebar — coming soon
+
+ + {/* Canvas */} +
+ + {/* Empty state */} + {state.nodes.length === 0 && ( +
+

Start by adding pins and generating your first page in the Setup tab

+

← Use the sidebar to get started

+
+ )} + + {/* Nodes will be rendered here — Tasks 4-6 */} +
+
+
+ ) +} +``` + +- [ ] Run `npm run dev` and visit `http://localhost:3000/canvas`. Verify: + - Dot grid background visible + - Pan with scroll/trackpad works + - Zoom with Ctrl+scroll/pinch works + - Empty state message shows + - Sidebar placeholder on left + +- [ ] Commit: `git add src/app/canvas/page.tsx && git commit -m "feat(canvas): add /canvas page with pan/zoom canvas and state management"` + +### Step 3.4 — Redirect root to /canvas + +- [ ] Replace `src/app/page.tsx` content with redirect: + +```typescript +import { redirect } from 'next/navigation' + +export default function Home() { + redirect('/canvas') +} +``` + +- [ ] Run `npm run dev`, visit `http://localhost:3000`, verify it redirects to `/canvas`. +- [ ] Commit: `git add src/app/page.tsx && git commit -m "feat(canvas): redirect / to /canvas"` + +--- + +## Task 4: Shape and Widget Nodes (roughjs) + +**Files:** +- Create: `src/components/canvas/ShapeNode.tsx` +- Create: `src/components/canvas/WidgetNode.tsx` +- Create: `src/components/canvas/CanvasNode.tsx` +- Modify: `src/app/canvas/page.tsx` + +### Step 4.1 — Create ShapeNode component + +- [ ] Create `src/components/canvas/ShapeNode.tsx`: + +```typescript +'use client' + +import { useEffect, useRef } from 'react' +import rough from 'roughjs' +import type { ShapeNodeData } from '@/lib/canvas-types' + +interface ShapeNodeProps { + width: number + height: number + data: ShapeNodeData + selected: boolean + onMouseDown: (e: React.MouseEvent) => void +} + +export default function ShapeNode({ width, height, data, selected, onMouseDown }: ShapeNodeProps) { + const svgRef = useRef(null) + + useEffect(() => { + if (!svgRef.current) return + const svg = svgRef.current + // Clear previous + while (svg.firstChild) svg.removeChild(svg.firstChild) + + const rc = rough.svg(svg) + const options = { + fill: data.fillColor || 'transparent', + stroke: data.strokeColor || 'var(--text)', + fillStyle: 'solid' as const, + roughness: 1.5, + } + + let node: SVGGElement + switch (data.shapeType) { + case 'rectangle': + node = rc.rectangle(2, 2, width - 4, height - 4, options) + break + case 'circle': + node = rc.circle(width / 2, height / 2, Math.min(width, height) - 4, options) + break + case 'rounded-rect': + node = rc.rectangle(2, 2, width - 4, height - 4, { ...options, roughness: 0.5 }) + break + case 'line': + node = rc.line(2, height / 2, width - 2, height / 2, options) + break + } + svg.appendChild(node) + }, [width, height, data]) + + return ( +
+ + {data.label && ( +
+ {data.label} +
+ )} +
+ ) +} +``` + +- [ ] Commit: `git add src/components/canvas/ShapeNode.tsx && git commit -m "feat(canvas): add ShapeNode with roughjs SVG rendering"` + +### Step 4.2 — Create WidgetNode component + +- [ ] Create `src/components/canvas/WidgetNode.tsx`: + +```typescript +'use client' + +import { useEffect, useRef } from 'react' +import rough from 'roughjs' +import type { WidgetNodeData } from '@/lib/canvas-types' + +interface WidgetNodeProps { + width: number + height: number + data: WidgetNodeData + selected: boolean + onMouseDown: (e: React.MouseEvent) => void +} + +const WIDGET_SHAPES: Record, w: number, h: number, opts: object) => SVGGElement> = { + button: (rc, w, h, opts) => rc.rectangle(2, 2, w - 4, h - 4, { ...opts, roughness: 0.3 }), + cta: (rc, w, h, opts) => rc.rectangle(2, 2, w - 4, h - 4, { ...opts, roughness: 0.3, fill: '#3b82f6', fillStyle: 'solid' }), + input: (rc, w, h, opts) => rc.rectangle(2, 2, w - 4, h - 4, { ...opts, roughness: 0.2 }), + dropdown: (rc, w, h, opts) => rc.rectangle(2, 2, w - 4, h - 4, { ...opts, roughness: 0.2 }), + navbar: (rc, w, h, opts) => rc.rectangle(2, 2, w - 4, h - 4, { ...opts, roughness: 0.5 }), + card: (rc, w, h, opts) => rc.rectangle(4, 4, w - 8, h - 8, { ...opts, roughness: 0.8 }), + hero: (rc, w, h, opts) => rc.rectangle(2, 2, w - 4, h - 4, { ...opts, roughness: 0.6 }), + footer: (rc, w, h, opts) => rc.rectangle(2, 2, w - 4, h - 4, { ...opts, roughness: 0.5 }), + checkbox: (rc, w, h, opts) => rc.rectangle(w / 2 - 8, h / 2 - 8, 16, 16, { ...opts, roughness: 1 }), + toggle: (rc, w, h, opts) => rc.rectangle(w / 2 - 16, h / 2 - 8, 32, 16, { ...opts, roughness: 0.5 }), +} + +export default function WidgetNode({ width, height, data, selected, onMouseDown }: WidgetNodeProps) { + const svgRef = useRef(null) + + useEffect(() => { + if (!svgRef.current) return + const svg = svgRef.current + while (svg.firstChild) svg.removeChild(svg.firstChild) + + const rc = rough.svg(svg) + const opts = { + stroke: data.strokeColor || 'var(--text)', + fill: data.fillColor || 'transparent', + fillStyle: 'solid' as const, + } + + const shapeFn = WIDGET_SHAPES[data.widgetType] || WIDGET_SHAPES.button + svg.appendChild(shapeFn(rc, width, height, opts)) + + // Dropdown arrow indicator + if (data.widgetType === 'dropdown') { + svg.appendChild(rc.line(width - 20, height / 2 - 3, width - 14, height / 2 + 3, opts)) + svg.appendChild(rc.line(width - 14, height / 2 + 3, width - 8, height / 2 - 3, opts)) + } + }, [width, height, data]) + + return ( +
+ +
+ {data.label} +
+
+ ) +} +``` + +- [ ] Commit: `git add src/components/canvas/WidgetNode.tsx && git commit -m "feat(canvas): add WidgetNode with roughjs SVG rendering"` + +### Step 4.3 — Create CanvasNode dispatcher + +- [ ] Create `src/components/canvas/CanvasNode.tsx`: + +```typescript +'use client' + +import { useCallback, useRef, useState } from 'react' +import type { CanvasNode as CanvasNodeType } from '@/lib/canvas-types' +import ShapeNode from './ShapeNode' +import WidgetNode from './WidgetNode' + +interface CanvasNodeProps { + node: CanvasNodeType + selected: boolean + zoom: number // current zoom scale for accurate drag + onSelect: (id: string, shiftKey: boolean) => void + onMove: (id: string, x: number, y: number) => void +} + +export default function CanvasNodeComponent({ node, selected, zoom, onSelect, onMove }: CanvasNodeProps) { + const dragRef = useRef<{ startX: number; startY: number; nodeX: number; nodeY: number } | null>(null) + const [dragging, setDragging] = useState(false) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + onSelect(node.id, e.shiftKey) + dragRef.current = { + startX: e.clientX, startY: e.clientY, + nodeX: node.x, nodeY: node.y, + } + setDragging(true) + + const handleMouseMove = (e: MouseEvent) => { + if (!dragRef.current) return + const dx = (e.clientX - dragRef.current.startX) / zoom + const dy = (e.clientY - dragRef.current.startY) / zoom + onMove(node.id, dragRef.current.nodeX + dx, dragRef.current.nodeY + dy) + } + + const handleMouseUp = () => { + dragRef.current = null + setDragging(false) + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + }, [node.id, node.x, node.y, onSelect, onMove]) + + const renderContent = () => { + switch (node.type) { + case 'shape': + return + case 'widget': + return + case 'image': + case 'document': + case 'artboard': + // Placeholder — Tasks 5-6 + return ( +
+ {node.type} (coming soon) +
+ ) + default: + return null + } + } + + return ( +
+ {renderContent()} +
+ ) +} +``` + +- [ ] Commit: `git add src/components/canvas/CanvasNode.tsx && git commit -m "feat(canvas): add CanvasNode dispatcher with drag support"` + +### Step 4.4 — Wire nodes into canvas page + +- [ ] Update `src/app/canvas/page.tsx` to render nodes. Add inside the `` children, after the empty state block: + +```typescript +{/* Render nodes */} +{state.nodes.map(node => ( + { + setSelectedIds(prev => { + const next = new Set(shift ? prev : []) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }} + onMove={(id, x, y) => { + pushState(moveNode(state, id, x, y)) + }} + /> +))} +``` + +Add the import at the top: +```typescript +import CanvasNodeComponent from '@/components/canvas/CanvasNode' +``` + +- [ ] Test manually: Add a temporary shape node to the initial state in `createEmptyState()` for visual verification, then remove it. +- [ ] Commit: `git add src/app/canvas/page.tsx && git commit -m "feat(canvas): render nodes on canvas with selection and drag"` + +--- + +## Task 5: Image and Document Nodes + +**Files:** +- Create: `src/components/canvas/ImageNode.tsx` +- Create: `src/components/canvas/DocumentNode.tsx` +- Modify: `src/components/canvas/CanvasNode.tsx` + +### Step 5.1 — Create ImageNode + +- [ ] Create `src/components/canvas/ImageNode.tsx`: + +```typescript +'use client' + +import { useState } from 'react' +import type { ImageNodeData } from '@/lib/canvas-types' + +interface ImageNodeProps { + width: number + height: number + data: ImageNodeData + selected: boolean + onMouseDown: (e: React.MouseEvent) => void +} + +export default function ImageNode({ width, height, data, selected, onMouseDown }: ImageNodeProps) { + const [error, setError] = useState(false) + const src = data.src.startsWith('http') ? data.src : `/api/uploads/${data.src.replace('uploads/', '')}` + + return ( +
+ {error ? ( +
+ Missing image +
+ ) : ( + {data.alt setError(true)} + draggable={false} + /> + )} +
+ ) +} +``` + +- [ ] Commit: `git add src/components/canvas/ImageNode.tsx && git commit -m "feat(canvas): add ImageNode with missing-file placeholder"` + +### Step 5.2 — Create DocumentNode + +- [ ] Create `src/components/canvas/DocumentNode.tsx`: + +```typescript +'use client' + +import { useState, useCallback } from 'react' +import type { DocNodeData } from '@/lib/canvas-types' + +interface DocumentNodeProps { + width: number + height: number + data: DocNodeData + selected: boolean + onMouseDown: (e: React.MouseEvent) => void + onUpdateData: (data: DocNodeData) => void +} + +// Simple markdown-to-HTML (headers, bold, italic, lists, paragraphs) +function renderMarkdown(md: string): string { + return md + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/\n\n/g, '

    ') + .replace(/\n/g, '
    ') +} + +export default function DocumentNode({ width, height, data, selected, onMouseDown, onUpdateData }: DocumentNodeProps) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(data.markdown) + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setEditing(true) + setDraft(data.markdown) + }, [data.markdown]) + + const handleBlur = useCallback(() => { + setEditing(false) + if (draft !== data.markdown) { + onUpdateData({ ...data, markdown: draft }) + } + }, [draft, data, onUpdateData]) + + return ( +
    + {data.title && ( +
    {data.title}
    + )} + {editing ? ( +