test: Built canvas type system, useCanvasState hook with undo/redo, art…

- "app/src/types/canvas.ts"
- "app/src/hooks/useCanvasState.ts"
- "app/src/hooks/__tests__/useCanvasState.test.ts"
- "app/src/utils/artboardShapes.ts"
- "app/src/utils/__tests__/artboardShapes.test.ts"
- "app/src/utils/alignment.ts"
- "app/src/utils/__tests__/alignment.test.ts"
- "app/src/components/canvas/ArtboardSetup.tsx"

GSD-Task: S02/T01
This commit is contained in:
jlightner 2026-03-26 05:32:04 +00:00
parent a97629c390
commit 59a034ab75
26 changed files with 2366 additions and 18 deletions

View file

@ -11,3 +11,4 @@
| D003 | | engine | Post-processing replaces regex metadata extraction | postprocess_svg() pipeline replaces old _extract_svg_metadata() regex approach; all SVG paths are parsed into structured PathInfo objects with coordinates, area, island status | Structured path data is needed for DXF/JSON output generation, RDP simplification, and island detection. XML parsing is now necessary since we need actual coordinate data, not just counts. This supersedes D003's regex approach. | No | agent | | D003 | | engine | Post-processing replaces regex metadata extraction | postprocess_svg() pipeline replaces old _extract_svg_metadata() regex approach; all SVG paths are parsed into structured PathInfo objects with coordinates, area, island status | Structured path data is needed for DXF/JSON output generation, RDP simplification, and island detection. XML parsing is now necessary since we need actual coordinate data, not just counts. This supersedes D003's regex approach. | No | agent |
| D004 | | architecture | Engine Docker image build strategy | Multi-stage build: builder stage compiles pypotrace with build-essential/libagg-dev/libpotrace-dev, runtime stage uses python:3.11-slim with only runtime libs (libpotrace0, libagg2, curl). Only engine source is copied — no App code. | Multi-stage keeps the runtime image small by excluding build tools. Copying only engine source enforces the Engine/App separation (D001). curl is included for Docker HEALTHCHECK. | Yes | agent | | D004 | | architecture | Engine Docker image build strategy | Multi-stage build: builder stage compiles pypotrace with build-essential/libagg-dev/libpotrace-dev, runtime stage uses python:3.11-slim with only runtime libs (libpotrace0, libagg2, curl). Only engine source is copied — no App code. | Multi-stage keeps the runtime image small by excluding build tools. Copying only engine source enforces the Engine/App separation (D001). curl is included for Docker HEALTHCHECK. | Yes | agent |
| D005 | | architecture | Frontend technology stack for Kerf App | Vite + React + TypeScript with plain CSS modules, Vitest for testing, raw fetch + FormData for engine API calls, no UI framework for V1 | Minimal dependency surface for a tool that needs fast iteration. The engine API uses multipart/form-data which works naturally with native fetch + FormData. CSS modules avoid global style conflicts without adding a framework. Vitest integrates natively with Vite's transform pipeline. No UI library needed — the app has a focused interface with ~8 components. | Yes | agent | | D005 | | architecture | Frontend technology stack for Kerf App | Vite + React + TypeScript with plain CSS modules, Vitest for testing, raw fetch + FormData for engine API calls, no UI framework for V1 | Minimal dependency surface for a tool that needs fast iteration. The engine API uses multipart/form-data which works naturally with native fetch + FormData. CSS modules avoid global style conflicts without adding a framework. Vitest integrates natively with Vite's transform pipeline. No UI library needed — the app has a focused interface with ~8 components. | Yes | agent |
| D006 | | frontend | React hook params stabilization strategy for useDebouncedTrace | JSON.stringify params in useEffect deps array, single-effect architecture | useCallback-based triggerTrace with object params caused infinite re-render loops because React creates new object references each render. JSON.stringify converts params to a stable string for dependency comparison, eliminating the need for callers to useMemo their params objects. Simpler API surface and no re-render bugs. | Yes | agent |

View file

@ -17,6 +17,9 @@ Agents read this before every unit. Add entries when you discover something wort
| P003 | PostProcessResult is the universal intermediate representation | engine/pipeline/postprocess.py → engine/output/ | All output generators (DXF, JSON, SVG) consume PostProcessResult. Never generate output directly from raw SVG strings. | | P003 | PostProcessResult is the universal intermediate representation | engine/pipeline/postprocess.py → engine/output/ | All output generators (DXF, JSON, SVG) consume PostProcessResult. Never generate output directly from raw SVG strings. |
| P004 | _format_response() for consistent multi-format API responses | engine/api/routes.py | DXF returns binary Response with X-Kerf-Metadata header; SVG/JSON return JSON envelopes. Use _format_response() for both /engine/trace and /engine/simplify. | | P004 | _format_response() for consistent multi-format API responses | engine/api/routes.py | DXF returns binary Response with X-Kerf-Metadata header; SVG/JSON return JSON envelopes. Use _format_response() for both /engine/trace and /engine/simplify. |
| P005 | Preset-driven pipeline: resolve_params() merges preset → user | engine/presets/loader.py, engine/api/routes.py | Presets define defaults for all pipeline stages. User params override. Mode comes from preset unless user explicitly sets it. | | P005 | Preset-driven pipeline: resolve_params() merges preset → user | engine/presets/loader.py, engine/api/routes.py | Presets define defaults for all pipeline stages. User params override. Mode comes from preset unless user explicitly sets it. |
| P006 | JSON.stringify for React hook dependency stabilization | app/src/hooks/useDebouncedTrace.ts | Object params in useEffect deps cause infinite loops (new ref each render). Stringify the params object and use the string in the deps array. Avoids requiring callers to useMemo. |
| P007 | Vite dev proxy for engine API calls | app/vite.config.ts | `/engine/*` requests proxy to `http://localhost:8000`. API client uses relative URLs (`/engine/presets`). No CORS issues in dev because same-origin via proxy. |
| P008 | CSS modules for views, global App.css for shared component styles | app/src/views/*.module.css, app/src/App.css | View-specific layout in CSS modules avoids conflicts. Shared styles (upload zone, preset cards, buttons) in App.css for cross-component reuse. |
## Lessons Learned ## Lessons Learned
@ -30,3 +33,5 @@ Agents read this before every unit. Add entries when you discover something wort
| L006 | postprocess_svg() fully parses SVG paths into coordinates | XML parsing + path d-attribute parsing gives structured PathInfo objects | This replaces the old regex-based SVG metadata extraction (D003). All output generators (DXF, JSON, SVG) consume PostProcessResult. | engine pipeline | | L006 | postprocess_svg() fully parses SVG paths into coordinates | XML parsing + path d-attribute parsing gives structured PathInfo objects | This replaces the old regex-based SVG metadata extraction (D003). All output generators (DXF, JSON, SVG) consume PostProcessResult. | engine pipeline |
| L007 | Docker build context must be project root, not engine/ | Dockerfile.engine uses `COPY engine/...` paths relative to project root | Always build with `docker build -f docker/Dockerfile.engine -t kerf-engine:dev .` from project root | Docker | | L007 | Docker build context must be project root, not engine/ | Dockerfile.engine uses `COPY engine/...` paths relative to project root | Always build with `docker build -f docker/Dockerfile.engine -t kerf-engine:dev .` from project root | Docker |
| L008 | Engine container has dual health endpoints | /health on root (main.py) and /engine/health on router (routes.py) both exist | Use /engine/health for Docker HEALTHCHECK and external probes for namespace consistency | engine API, Docker | | L008 | Engine container has dual health endpoints | /health on root (main.py) and /engine/health on router (routes.py) both exist | Use /engine/health for Docker HEALTHCHECK and external probes for namespace consistency | engine API, Docker |
| L009 | useCallback-based triggerTrace caused infinite re-render loops | Object params create new references each render, retriggering useEffect even when values are unchanged | Restructure hook to use single useEffect with JSON.stringify(params) in dependency array instead of useCallback-based approach | app hooks, React |
| L010 | jest-canvas-mock crashes in Vitest with "ReferenceError: jest is not defined" | jest-canvas-mock internally calls jest.fn() in createImageBitmap.js — Jest globals don't exist in Vitest | Use vitest-canvas-mock (a Vitest-compatible fork) instead. Import it in test-setup.ts. | app tests, canvas |

View file

@ -17,3 +17,6 @@
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S01","taskId":"T02"},"ts":"2026-03-26T05:07:29.861Z","actor":"agent","hash":"a3980272c7b74afa","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S01","taskId":"T02"},"ts":"2026-03-26T05:07:29.861Z","actor":"agent","hash":"a3980272c7b74afa","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S01","taskId":"T03"},"ts":"2026-03-26T05:15:38.849Z","actor":"agent","hash":"51de22a58ca5b075","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S01","taskId":"T03"},"ts":"2026-03-26T05:15:38.849Z","actor":"agent","hash":"51de22a58ca5b075","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S01","taskId":"T04"},"ts":"2026-03-26T05:17:44.460Z","actor":"agent","hash":"fd1cf932b3152ba6","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S01","taskId":"T04"},"ts":"2026-03-26T05:17:44.460Z","actor":"agent","hash":"fd1cf932b3152ba6","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-slice","params":{"milestoneId":"M002","sliceId":"S01"},"ts":"2026-03-26T05:20:14.729Z","actor":"agent","hash":"4e07aca5d7cb85a5","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"plan-slice","params":{"milestoneId":"M002","sliceId":"S02"},"ts":"2026-03-26T05:26:15.488Z","actor":"agent","hash":"b1dbe18979c01969","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T01"},"ts":"2026-03-26T05:31:55.544Z","actor":"agent","hash":"4c3809e0b1681b4c","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}

View file

@ -6,6 +6,6 @@ Build the React frontend with the Import & Convert view (View 1) and the Design
## Slice Overview ## Slice Overview
| ID | Slice | Risk | Depends | Done | After this | | ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------| |----|-------|------|---------|------|------------|
| S01 | Import & Convert UI (View 1) | medium — debounced preview updates, engine api integration from browser | — | | Upload a PNG, see live preview with preset selection and slider controls, click 'Use This' to advance to canvas | | S01 | Import & Convert UI (View 1) | medium — debounced preview updates, engine api integration from browser | — | | Upload a PNG, see live preview with preset selection and slider controls, click 'Use This' to advance to canvas |
| S02 | Design Canvas Core (View 2) | medium — konva.js setup, selection handles, undo/redo state management | S01 | ⬜ | Create artboards in each shape, add/move/resize shapes, multi-select with alignment, undo/redo through history | | S02 | Design Canvas Core (View 2) | medium — konva.js setup, selection handles, undo/redo state management | S01 | ⬜ | Create artboards in each shape, add/move/resize shapes, multi-select with alignment, undo/redo through history |
| S03 | Text System + Font Loading | medium — opentype.js integration, font loading from volume, path extraction accuracy | S02 | ⬜ | Add text objects, select fonts from picker, change size/spacing, convert text to paths — paths match original glyphs | | S03 | Text System + Font Loading | medium — opentype.js integration, font loading from volume, path extraction accuracy | S02 | ⬜ | Add text objects, select fonts from picker, change size/spacing, convert text to paths — paths match original glyphs |

View file

@ -0,0 +1,135 @@
---
id: S01
parent: M002
milestone: M002
provides:
- Typed API client (getPresets, traceImage, simplifyVector) with AbortSignal support
- ViewState routing in App.tsx — S02 adds 'canvas' view rendering
- onUseThis callback passes SVG string + TraceMetadata from View 1 to View 2
- useDebouncedTrace hook pattern reusable for any debounced API interaction
- TypeScript interfaces for all engine API types (PresetConfig, TraceResponse, TraceMetadata)
requires:
[]
affects:
- S02
key_files:
- app/src/api/engine.ts
- app/src/types/engine.ts
- app/src/hooks/useDebouncedTrace.ts
- app/src/views/ImportConvert.tsx
- app/src/components/FileUpload.tsx
- app/src/components/PresetSelector.tsx
- app/src/components/ParameterSliders.tsx
- app/src/components/SvgPreview.tsx
- app/src/components/OutputInfoBar.tsx
- app/src/App.tsx
- app/vite.config.ts
- engine/main.py
key_decisions:
- D005: Vite + React + TS with plain CSS modules, Vitest, raw fetch — minimal dependency surface
- D006: JSON.stringify params stabilization in useDebouncedTrace to avoid infinite re-render loops
- CORS allows all origins for dev with comment to restrict in production
- Use This button always visible but disabled when no SVG — avoids layout shift
patterns_established:
- P006: JSON.stringify for React hook dependency stabilization of object params
- P007: Vite dev proxy for engine API — relative URLs, no CORS issues in dev
- P008: CSS modules for views, global App.css for shared component styles
- ViewState routing via useState in App.tsx — simple state machine (import → canvas → export)
- Mode-aware UI: components inspect preset vectorization mode to show/hide relevant controls
observability_surfaces:
- OutputInfoBar color coding: green=normal, yellow=high node count (>5000), red=open paths (>0)
- TraceMetadata.warnings array surfaced in OutputInfoBar when present
- Processing time displayed in stats bar
drill_down_paths:
- .gsd/milestones/M002/slices/S01/tasks/T01-SUMMARY.md
- .gsd/milestones/M002/slices/S01/tasks/T02-SUMMARY.md
- .gsd/milestones/M002/slices/S01/tasks/T03-SUMMARY.md
- .gsd/milestones/M002/slices/S01/tasks/T04-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-03-26T05:20:14.695Z
blocker_discovered: false
---
# S01: Import & Convert UI (View 1)
**Built the complete Import & Convert view (View 1) — file upload, preset selection, debounced live vectorization preview with parameter sliders, output stats bar, and Use This button to advance to canvas.**
## What Happened
This slice delivered the full View 1 of the Kerf App: a React SPA that integrates with the Engine API for interactive vectorization.
**T01 — Foundation:** Added CORSMiddleware to engine/main.py for browser API access. Scaffolded the app/ directory as a Vite + React + TypeScript project with a dev proxy forwarding /engine/* requests to localhost:8000. Built typed API client (getPresets, traceImage, simplifyVector) with AbortSignal support and error handling. Established the test infrastructure with Vitest + jsdom + testing-library. 9 tests pass.
**T02 — UI Shell:** Created the two-column ImportConvert layout (left: controls, right: preview). Built FileUpload component with drag-and-drop + file input fallback, thumbnail preview via URL.createObjectURL, and SVG file detection for mode switching. Built PresetSelector that fetches presets from the engine API on mount with AbortController cleanup, renders preset cards with selection highlighting, and auto-selects 'sign' as default. Set up ViewState routing in App.tsx (import → canvas → export).
**T03 — Core Interaction Loop (highest risk):** Created the useDebouncedTrace custom hook — the central piece — which debounces API calls on params/preset/file change, aborts in-flight requests via AbortController, and routes SVG uploads to simplifyVector() instead of traceImage(). Solved infinite re-render loops by stabilizing params via JSON.stringify in the useEffect dependency array. Built ParameterSliders with mode-aware controls (potrace shows epsilon/turdsize/alphamax; vtracer shows epsilon/filter_speckle/corner_threshold). Built SvgPreview with responsive SVG rendering (strips width/height, preserves viewBox), loading spinner, and error states. Wired the complete live re-trace loop in ImportConvert. 7 hook tests pass.
**T04 — Polish & Completion:** Created OutputInfoBar with color-coded stats (green normal, yellow >5000 nodes, red open paths), warnings display, and processing time. Wired the Use This button (disabled when no SVG output) that passes SVG + metadata to App.tsx for View 2 consumption. 7 component tests pass.
All 23 tests pass. Zero TypeScript errors.
## Verification
All slice-level verification checks pass:
- `cd app && npx vitest run --reporter=verbose`: 23/23 tests pass across 3 test files (9 API client, 7 OutputInfoBar, 7 useDebouncedTrace)
- `cd app && npx tsc --noEmit`: zero TypeScript errors
- All planned files exist: API client, types, 6 components, 1 custom hook, 2 views, CSS modules, 3 test files
- CORS middleware confirmed in engine/main.py
- Vite dev proxy confirmed in vite.config.ts
## Requirements Advanced
None.
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
Minor deviations, none plan-breaking:
- T01: Added @types/node and test-setup.ts (not in plan) for proper type resolution and jest-dom matchers
- T03: Use This button was wired in T03 instead of T04 — T04 retained it and added disabled state logic
- T03: Hook architecture changed from useCallback-based trigger to single-useEffect with JSON.stringify params stabilization to avoid infinite re-render loops
## Known Limitations
- CORS allows all origins (appropriate for dev, needs restriction for production)
- SVG preview uses dangerouslySetInnerHTML — acceptable for engine-generated SVG but should not be used for untrusted SVG in production
- No end-to-end integration test with a running engine (unit tests mock fetch)
## Follow-ups
- S02 consumes the SVG string and TraceMetadata passed via onUseThis callback in App.tsx
- Production CORS configuration should be added when deployment is set up (M003)
- Consider adding SVG sanitization if untrusted input becomes possible
## Files Created/Modified
- `engine/main.py` — Added CORSMiddleware allowing all origins for dev
- `app/vite.config.ts` — Vite config with React plugin, dev proxy to engine, Vitest config
- `app/src/types/engine.ts` — TypeScript interfaces for PresetConfig, TraceResponse, TraceMetadata, PresetsResponse
- `app/src/api/engine.ts` — Typed API client: getPresets(), traceImage(), simplifyVector() with AbortSignal
- `app/src/api/__tests__/engine.test.ts` — 9 unit tests for API client covering URL/method/FormData/AbortSignal/errors
- `app/src/hooks/useDebouncedTrace.ts` — Custom hook: debounced trace with AbortController, SVG mode detection, params stabilization
- `app/src/hooks/__tests__/useDebouncedTrace.test.ts` — 7 tests for debounce, abort, SVG routing, error handling, cleanup
- `app/src/views/ImportConvert.tsx` — View 1 container: two-column layout wiring FileUpload, PresetSelector, ParameterSliders, SvgPreview, OutputInfoBar, Use This button
- `app/src/views/ImportConvert.module.css` — CSS module for ImportConvert layout
- `app/src/components/FileUpload.tsx` — Drag-and-drop file upload with thumbnail preview and SVG detection
- `app/src/components/PresetSelector.tsx` — Fetches presets from engine, renders cards with selection state, auto-selects sign
- `app/src/components/ParameterSliders.tsx` — Mode-aware range sliders for vectorization parameters
- `app/src/components/SvgPreview.tsx` — Responsive SVG rendering with loading/error states
- `app/src/components/OutputInfoBar.tsx` — Color-coded stats bar with warnings display
- `app/src/components/__tests__/OutputInfoBar.test.tsx` — 7 tests for color coding logic and edge cases
- `app/src/App.tsx` — ViewState routing, SVG/metadata state for View 2 handoff
- `app/src/App.css` — Global styles for shared components
- `app/src/test-setup.ts` — Vitest setup with jest-dom matchers

View file

@ -0,0 +1,114 @@
# S01: Import & Convert UI (View 1) — UAT
**Milestone:** M002
**Written:** 2026-03-26T05:20:14.695Z
## UAT: S01 — Import & Convert UI (View 1)
### Preconditions
- Engine running at `http://localhost:8000` with presets loaded (`GET /engine/presets` returns preset configs)
- App dev server running via `cd app && npm run dev` (Vite at localhost:5173)
- At least one test raster image available (PNG, JPG, or BMP)
- At least one test SVG file available
---
### Test 1: File Upload — Drag and Drop
1. Open `http://localhost:5173` in browser
2. Verify the Import & Convert view loads with a dashed-border upload zone on the left panel and empty preview area on the right
3. Drag a PNG file onto the upload zone
4. **Expected:** Upload zone shows the filename, file size, and a thumbnail preview of the image
5. Verify the thumbnail is visible and roughly matches the uploaded image
### Test 2: File Upload — Click Fallback
1. Click on the upload zone (not drag-and-drop)
2. **Expected:** A native file picker dialog opens
3. Select a JPG file
4. **Expected:** Same result as Test 1 — filename, size, and thumbnail displayed
### Test 3: File Upload — SVG Detection
1. Upload an SVG file (via drag or click)
2. **Expected:** File is accepted and thumbnail/filename shown
3. Observe that when trace fires, it calls `/engine/simplify` (not `/engine/trace`) — verify in browser devtools Network tab
### Test 4: Preset Selection
1. Upload a raster image (PNG)
2. Verify preset cards appear below the upload zone, fetched from the engine API
3. **Expected:** 'sign' preset is selected by default (highlighted)
4. Click a different preset card (e.g., 'technical' or 'photo')
5. **Expected:** The clicked preset becomes highlighted, the previous one unhighlights
6. **Expected:** The SVG preview updates automatically after a short debounce delay
### Test 5: Live Debounced Re-Trace
1. Upload a raster PNG with the 'sign' preset selected
2. Wait for the initial SVG preview to appear in the right panel
3. **Expected:** Preview shows a vectorized SVG version of the uploaded image
4. Move the "Detail Level" slider
5. **Expected:** After ~300ms debounce, the preview updates with the new vectorization result
6. Rapidly move sliders 3-4 times in quick succession
7. **Expected:** Only the final parameter set triggers an API call (debounce coalesces rapid changes)
### Test 6: Parameter Sliders — Potrace Mode
1. Select a preset that uses potrace mode (e.g., 'sign')
2. **Expected:** Sliders shown: Detail Level (epsilon), Noise Filter (turdsize), Smooth Curves (alphamax)
3. **Expected:** Corner Threshold slider is NOT shown (vtracer-only)
4. Each slider displays its current numeric value
### Test 7: Parameter Sliders — VTracer Mode
1. Select a preset that uses vtracer mode (if available)
2. **Expected:** Sliders shown: Detail Level (epsilon), Noise Filter (filter_speckle), Corner Threshold (corner_threshold)
3. **Expected:** Smooth Curves (alphamax) slider is NOT shown (potrace-only)
### Test 8: SVG Preview — Responsive Rendering
1. Upload an image and wait for SVG preview
2. Resize the browser window
3. **Expected:** SVG preview scales responsively (maintains aspect ratio, fills available width, max-height 70vh)
4. **Expected:** No horizontal scrollbars or overflow
### Test 9: Output Info Bar — Normal Stats
1. Upload an image, wait for vectorization to complete
2. **Expected:** Stats bar appears below the preview showing: Path Count, Total Nodes, Open Paths, Processing Time
3. **Expected:** All indicators are green for typical images with moderate complexity
### Test 10: Output Info Bar — Warning Indicators
1. Upload a complex/detailed image that produces >5000 total nodes
2. **Expected:** Total Nodes indicator turns yellow
3. If the result has open paths > 0: **Expected:** Open Paths indicator turns red
4. If engine returns warnings: **Expected:** Warnings are displayed in the info bar
### Test 11: Use This Button — Disabled State
1. Load the page fresh (no file uploaded)
2. **Expected:** "Use This" button is visible but disabled/grayed out
3. Upload a file but before the trace completes:
4. **Expected:** Button remains disabled while loading
### Test 12: Use This Button — Advances to Canvas
1. Upload a file, wait for SVG preview to appear
2. **Expected:** "Use This" button becomes enabled
3. Click the "Use This" button
4. **Expected:** View transitions from Import & Convert to the Design Canvas placeholder (shows "View 2: Design Canvas")
### Test 13: Error Handling — API Failure
1. Stop the engine server
2. Upload a file
3. **Expected:** Preview area shows an error message (not a crash or blank screen)
4. Restart the engine
5. Re-upload or change a slider
6. **Expected:** Preview recovers and shows the vectorized result
### Test 14: AbortController Cleanup
1. Upload a file and immediately upload a different file before the first trace completes
2. **Expected:** First request is aborted (visible in devtools Network tab as cancelled), second request completes normally
3. Only the second file's result is shown in the preview
### Edge Cases
### Test 15: Large File Upload
1. Upload a large image (>5MB)
2. **Expected:** Upload completes, thumbnail shown, vectorization runs (may take longer than usual)
3. No UI freezing during processing
### Test 16: Rapid Preset Switching
1. Upload a file, then rapidly click through 3-4 different presets
2. **Expected:** Intermediate requests are aborted, only the final preset's result is displayed
3. No stale results from earlier presets appear after the final result loads

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"taskId": "T04",
"unitId": "M002/S01/T04",
"timestamp": 1774502268928,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd app",
"exitCode": 0,
"durationMs": 5,
"verdict": "pass"
}
]
}

View file

@ -1,6 +1,90 @@
# S02: Design Canvas Core (View 2) # S02: Design Canvas Core (View 2)
**Goal:** Build View 2 Design Canvas with Konva.js — artboard setup, basic shapes, object panel, toolbar, alignment tools, undo/redo **Goal:** Build the Konva.js-powered 2D design canvas (View 2) with artboard shapes, basic shapes (rect/circle/ellipse/line), imported SVG rendering, selection handles, multi-select, object/layer panel with reorder, alignment tools, and undo/redo.
**Demo:** After this: Create artboards in each shape, add/move/resize shapes, multi-select with alignment, undo/redo through history **Demo:** After this: Create artboards in each shape, add/move/resize shapes, multi-select with alignment, undo/redo through history
## Tasks ## Tasks
- [x] **T01: Built canvas type system, useCanvasState hook with undo/redo, artboard shapes, alignment utils, ArtboardSetup component, and 48 new tests passing** — Establish the foundation that every other task depends on: canvas type definitions (discriminated union for canvas objects, artboard config, canvas state), the central useCanvasState hook with full CRUD + selection + undo/redo, artboard shape utilities, the ArtboardSetup component, and canvas mock setup for Vitest. This is the riskiest piece — the state model shape affects every downstream component.
The useCanvasState hook is a useReducer + useRef pattern: current state in a reducer (triggers re-renders), history stack in a useRef (avoids re-renders on history push). Every mutation dispatches through the reducer which also pushes a history snapshot. Undo/redo pops from history.
The ArtboardSetup component renders a modal overlay on first entering the canvas view with shape picker (rect, square, circle, oval, shield, pennant, custom), width/height number inputs, and inches/mm units toggle. On confirm, it produces an ArtboardConfig that initializes the canvas.
Install konva, react-konva, and jest-canvas-mock as dependencies. Set up canvas mock in test-setup.ts so Konva components don't crash in jsdom.
- Estimate: 2h
- Files: app/src/types/canvas.ts, app/src/hooks/useCanvasState.ts, app/src/hooks/__tests__/useCanvasState.test.ts, app/src/utils/artboardShapes.ts, app/src/utils/__tests__/artboardShapes.test.ts, app/src/utils/alignment.ts, app/src/utils/__tests__/alignment.test.ts, app/src/components/canvas/ArtboardSetup.tsx, app/src/test-setup.ts, app/package.json
- Verify: cd app && npx vitest run --reporter=verbose && npx tsc --noEmit
- [ ] **T02: Konva stage with artboard rendering, imported SVG, selection handles, and shape tools** — Build the core Konva canvas rendering layer and wire it into the app. This task creates the KonvaStage component (Konva Stage + Layer with artboard background, all canvas objects, Transformer for selection handles, rubber-band selection rectangle), the DesignCanvas view container, and shape creation tools.
Key implementation details:
- DesignCanvas is the View 2 container. Layout: top toolbar area, left canvas with KonvaStage, right panel area (wired in T03). Receives svgData and traceMetadata props from App.tsx.
- Wire DesignCanvas into App.tsx replacing the placeholder div. Remove underscore prefixes from _svgResult and _traceMetadata variables.
- KonvaStage renders: (1) artboard background shape using Konva.Rect/Path/Circle based on ArtboardConfig, (2) all canvas objects from useCanvasState mapped to Konva components, (3) Transformer attached to selected nodes, (4) rubber-band selection rectangle for multi-select.
- Imported SVG from View 1: convert SVG string → Blob URL → Image element → Konva.Image. Auto-add to canvas objects on mount if svgData is provided.
- Shape tools: rect, circle, ellipse, line creation. Click canvas in tool mode to place shape at click position with default dimensions. Tool state tracked in DesignCanvas.
- Selection: click shape to select (single), shift-click to add to selection, click empty area to deselect, rubber-band rectangle selects all intersecting shapes.
- Transformer: on onTransformEnd, reset scaleX/scaleY to 1 and multiply into width/height to keep state model clean.
- Line objects support dash arrays for style variants (solid, dashed, dotted).
- Canvas view should fill available width. Override #root width constraint for canvas view or make canvas flex within it.
IMPORTANT tsconfig constraints: noUnusedLocals and noUnusedParameters are strict. All variables must be used or prefixed with underscore.
- Estimate: 2h30m
- Files: app/src/views/DesignCanvas.tsx, app/src/views/DesignCanvas.module.css, app/src/components/canvas/KonvaStage.tsx, app/src/App.tsx, app/src/App.css
- Verify: cd app && npx tsc --noEmit && npx vitest run --reporter=verbose
- [ ] **T03: Object panel, alignment bar, canvas toolbar, and shape properties panel** — Build the UI panels that surround the canvas: the right-side ObjectPanel for layer management, the AlignmentBar for spatial arrangement, the CanvasToolbar for tool switching and canvas controls, and the ShapeProperties panel for editing selected objects.
ObjectPanel:
- Lists canvas objects by z-order (top of list = frontmost layer)
- Each row shows: drag handle, object type icon, name (double-click to rename), visibility toggle (eye icon), lock toggle (lock icon)
- Click row to select object on canvas, shift-click for multi-select
- Drag-to-reorder changes z-order in state (which changes render order in KonvaStage)
- Wire to useCanvasState: reorderObject, toggleVisibility, toggleLock, selectObjects
AlignmentBar:
- Appears when 1+ objects selected
- Buttons: align left, align center, align right, align top, align middle, align bottom
- When 2+ selected: distribute horizontally, distribute vertically
- Center on artboard button (works with 1+ selected)
- Consumes alignment utility functions from utils/alignment.ts
- Dispatches batch updateObject calls through useCanvasState
CanvasToolbar:
- Tool buttons: pointer/select (default), rectangle, circle, ellipse, line
- Active tool highlighted with accent color
- Undo/redo buttons (disabled when stack empty) — connected to useCanvasState undo/redo
- Grid toggle button
- Zoom controls (zoom in, zoom out, fit to artboard)
ShapeProperties:
- Shown when exactly 1 shape selected
- Fields: stroke color (color input), stroke weight (number input), fill color (color input with toggle), width/height display
- For line objects: line style dropdown (solid, dashed, dotted)
- Changes dispatch updateObject through useCanvasState
Wire all panels into DesignCanvas.tsx layout slots.
- Estimate: 2h
- Files: app/src/components/canvas/ObjectPanel.tsx, app/src/components/canvas/AlignmentBar.tsx, app/src/components/canvas/CanvasToolbar.tsx, app/src/components/canvas/ShapeProperties.tsx, app/src/views/DesignCanvas.tsx, app/src/views/DesignCanvas.module.css, app/src/App.css
- Verify: cd app && npx tsc --noEmit && npx vitest run --reporter=verbose
- [ ] **T04: Keyboard shortcuts, final integration wiring, and verification** — Wire keyboard shortcuts for the canvas, perform final integration checks, and ensure all tests pass.
Keyboard shortcuts:
- Ctrl+Z: undo (calls useCanvasState.undo)
- Ctrl+Shift+Z (or Ctrl+Y): redo (calls useCanvasState.redo)
- Delete/Backspace: remove selected objects (calls useCanvasState.removeObject for each selected ID)
- Escape: deselect all (calls useCanvasState.deselectAll)
- Ctrl+A: select all objects
Implement via useEffect with keydown listener on the DesignCanvas container div (or window). Ensure shortcuts only fire when canvas view is active and no text input is focused (check activeElement).
Final integration:
- Verify the full flow: View 1 → Use This → Artboard Setup → Canvas with imported SVG
- Verify undo/redo buttons in toolbar reflect stack state (disabled when empty)
- Verify all panel components render and dispatch correctly
- Run full test suite: all existing 23 tests + all new tests must pass
- Run TypeScript compiler: zero errors
- Ensure no unused imports/variables (strict tsconfig)
This task closes the slice by ensuring everything works together as an integrated whole.
- Estimate: 1h
- Files: app/src/views/DesignCanvas.tsx, app/src/App.css
- Verify: cd app && npx vitest run --reporter=verbose && npx tsc --noEmit

View file

@ -0,0 +1,124 @@
# S02 — Design Canvas Core (View 2) — Research
**Date:** 2026-03-26
## Summary
S02 builds View 2, the Konva.js-powered 2D design canvas. This is a moderately complex slice with well-documented technology (react-konva has excellent examples for every feature needed), a clear handoff from S01 (SVG string + TraceMetadata in App.tsx state), and five requirements to satisfy: R013 (artboard shapes), R015 (basic shapes with selection/resize), R016 (object/layer panel), R017 (alignment tools), R018 (undo/redo).
The core risk is state management complexity — the canvas needs a central store of typed objects with undo/redo across all mutations (add, move, resize, reorder, delete, property changes). react-konva's `Transformer` component handles selection handles and resize/rotate natively; multi-select uses `tr.nodes([...])`. The official Konva docs provide React examples for every interaction pattern needed (Transformer, multi-select with rubber-band, drag-and-drop reordering, undo/redo via history stack, dash patterns for line styles, z-index layering). No custom rendering engine needed.
The imported SVG from View 1 should be rendered via `Konva.Image` from a Blob URL — this is simpler and more reliable than parsing individual SVG paths, preserves visual fidelity, and keeps the vector as a single moveable/scaleable/rotatable object on canvas. Text objects are deferred to S03. Shield and pennant artboard shapes are custom SVG path outlines rendered as Konva.Path clip masks.
## Recommendation
**Build in this order: state model → artboard → Konva stage with imported SVG → shape tools + selection → object panel → alignment → undo/redo.**
Use a single `useCanvasState` hook (or a context + reducer) as the central state store. All canvas objects live in an array with typed discriminated unions (`type: 'rect' | 'circle' | 'ellipse' | 'line' | 'imported-svg'`). Every mutation goes through a dispatch function that records a snapshot for undo/redo. The Konva `Stage` renders objects from this store; `Transformer` handles interactive resize/rotate. The object panel reads and reorders the same store.
Install `konva` and `react-konva` as dependencies. No other new libraries needed — alignment math and undo/redo are simple enough to implement inline. Konva.js handles all 2D rendering, hit detection, drag, and transform.
## Implementation Landscape
### Key Files (existing)
- `app/src/App.tsx` — ViewState routing (`import | canvas | export`). Currently stores `_svgResult` (string) and `_traceMetadata` (TraceMetadata) via `handleUseThis`. The `view === 'canvas'` branch renders a placeholder div that S02 replaces with `<DesignCanvas svgData={svgResult} metadata={traceMetadata} />`.
- `app/src/types/engine.ts` — TraceMetadata interface (consumed by canvas for metadata display).
- `app/src/App.css` — Global component styles. S02 adds canvas toolbar, object panel, and artboard setup styles here.
- `app/src/index.css` — CSS variables (--accent, --border, --bg, --text-h, etc.) and base typography. Canvas UI must use these.
- `app/vite.config.ts` — Vite + React + Vitest config. No changes needed.
- `app/src/test-setup.ts` — Vitest setup with jest-dom matchers. May need `canvas` mock setup for Konva in jsdom.
### Key Files (new)
- `app/src/types/canvas.ts` — Canvas object type definitions: `CanvasObject` discriminated union (`RectObject | CircleObject | EllipseObject | LineObject | ImportedSvgObject`), `ArtboardConfig` (shape + dimensions + units), `CanvasState` (objects array, selectedIds, artboard config, history stack).
- `app/src/hooks/useCanvasState.ts` — Central state management hook. Manages: object CRUD, selection (single + multi), artboard config, undo/redo history. Returns state + dispatch functions. History stored as `useRef<CanvasState[]>` with a step pointer (same pattern as Konva's official undo/redo example — `useRef` avoids unnecessary re-renders on history push).
- `app/src/views/DesignCanvas.tsx` — View 2 container. Layout: top toolbar, left canvas area with Konva Stage, right object panel. Receives `svgData` and `metadata` props from App.tsx.
- `app/src/views/DesignCanvas.module.css` — CSS module for canvas layout (follows P008 pattern).
- `app/src/components/canvas/ArtboardSetup.tsx` — Modal/overlay shown on first entering canvas. Shape picker (rect, square, circle, oval, shield, pennant, custom), width/height inputs, units toggle (inches/mm). Produces `ArtboardConfig`.
- `app/src/components/canvas/KonvaStage.tsx` — Wraps `<Stage>` + `<Layer>`. Renders artboard background shape, all canvas objects, selection `Transformer`, rubber-band selection rectangle. Handles click-to-select, shift-click multi-select, click-on-empty to deselect.
- `app/src/components/canvas/CanvasToolbar.tsx` — Top toolbar: select tool, shape tools (rect, circle, ellipse, line), zoom controls, undo/redo buttons, grid toggle.
- `app/src/components/canvas/ObjectPanel.tsx` — Right sidebar: lists objects by layer order (top = front), drag-to-reorder, visibility toggle (eye icon), lock toggle (lock icon), click-to-select, double-click-to-rename.
- `app/src/components/canvas/AlignmentBar.tsx` — Appears when objects selected: align left/center/right/top/middle/bottom, distribute H/V, center on artboard.
- `app/src/components/canvas/ShapeProperties.tsx` — Property panel for selected shape: stroke color, stroke weight, fill toggle, line style (for line objects), dimensions display.
- `app/src/utils/artboardShapes.ts` — SVG path data for non-primitive artboard shapes (shield, pennant). Also artboard dimension presets. Pure functions, easily testable.
- `app/src/utils/alignment.ts` — Alignment calculation functions: align, distribute, center-on-artboard. Pure math, easily testable.
### Build Order
**1. Foundation — types + state hook + artboard setup (highest priority, unblocks everything)**
- Define `CanvasObject` types in `canvas.ts`
- Build `useCanvasState` hook with add/remove/update/select/deselect/reorder + undo/redo
- Build `ArtboardSetup` component for shape/dimension selection
- This is the riskiest piece — state shape affects every downstream component. Get it right first.
**2. Konva Stage + imported SVG rendering**
- Install `konva` and `react-konva`
- Build `KonvaStage` with artboard background rendering
- Wire imported SVG from S01 as a `Konva.Image` (convert SVG string → Blob URL → Image)
- Add selection via `Transformer`, single + multi-select, deselect on empty click
- Wire `DesignCanvas` view into App.tsx (replace placeholder)
**3. Shape tools + selection interaction**
- Add shape creation tools (rect, circle, ellipse, line)
- Wire toolbar buttons for tool switching
- Implement rubber-band selection rectangle for multi-select
- Line style variants (solid, dashed, dotted, double, centerline via dash arrays)
**4. Object panel + alignment**
- Build `ObjectPanel` with layer list, reorder, visibility, lock
- Build `AlignmentBar` with align/distribute/center operations
- Wire both to `useCanvasState`
**5. Undo/redo + keyboard shortcuts**
- Wire Ctrl+Z / Ctrl+Shift+Z to undo/redo
- Undo/redo buttons in toolbar
- Delete key removes selected objects
### Verification Approach
- `cd app && npx vitest run --reporter=verbose` — all tests pass (existing 23 + new)
- `cd app && npx tsc --noEmit` — zero TypeScript errors
- Unit tests for pure functions: `alignment.ts` (align calculations), `artboardShapes.ts` (path data generation), `useCanvasState` (add/remove/update/select/undo/redo state transitions)
- Component tests: `ArtboardSetup` (shape selection produces correct config), `ObjectPanel` (renders object list, reorder dispatches correct action), `AlignmentBar` (button clicks dispatch alignment)
- Konva rendering tests may require mocking `canvas` in jsdom — `jest-canvas-mock` or `vitest-canvas-mock` package, or test pure state logic separately from rendering
- Manual visual verification: start dev server (`cd app && npm run dev`), navigate to canvas, create artboard, add shapes, verify selection handles, undo/redo
## Don't Hand-Roll
| Problem | Existing Solution | Why Use It |
|---------|------------------|------------|
| 2D canvas rendering + hit detection | `konva` + `react-konva` | Official React bindings for Konva.js. Handles all canvas rendering, event handling, drag, transform. Well-documented with React examples for every pattern we need. |
| Selection handles (resize/rotate) | Konva `Transformer` component | Built-in to react-konva. Handles resize handles, rotation, multi-node selection, min-size constraints via `boundBoxFunc`. |
| Multi-select rubber-band | Konva selection rectangle pattern | Official example uses `selectionRectangle.getClientRect()` + `Konva.Util.haveIntersection()` — exact pattern documented in Konva site. |
## Constraints
- **jsdom has no Canvas API** — Konva requires `<canvas>`. Tests that import react-konva components will fail without a canvas mock (`jest-canvas-mock` or `vitest-canvas-mock`). Structure tests to isolate pure state logic (testable without canvas) from Konva rendering (needs mock or integration test).
- **noUnusedLocals + noUnusedParameters strict** — tsconfig.app.json has these enabled. All variables and params must be used or prefixed with `_`. S01 already uses `_svgResult` and `_traceMetadata` in App.tsx — S02 removes the underscores when these are consumed.
- **`#root` has `width: 1126px` and `border-inline`** — Canvas view needs to either work within this container or override it. Canvas should probably fill available space. Consider using `width: 100%` or adjusting root styles for the canvas view.
- **SVG from View 1 is a raw string** — To render on Konva canvas, convert to Blob URL → Image element → `Konva.Image`. Cannot use `dangerouslySetInnerHTML` in canvas context.
- **CSS modules for view layout, global App.css for shared components** (P008) — Follow this pattern for DesignCanvas.module.css and shared toolbar/panel styles.
## Common Pitfalls
- **Transformer scale vs dimensions** — When a shape is resized via Transformer, Konva applies `scaleX/scaleY` rather than changing `width/height`. On `onTransformEnd`, reset scale to 1 and multiply into width/height to keep the state model clean. This is documented in every Konva Transformer example.
- **Undo/redo with refs vs state** — Store history in `useRef` (not `useState`) to avoid re-renders on every history push. Only the "current state" triggers re-renders. The official Konva undo/redo example uses this pattern.
- **Multi-select Transformer attachment** — Must call `transformerRef.current.nodes([...nodeRefs])` in a `useEffect` when selection changes, then `getLayer().batchDraw()`. Forgetting `batchDraw` causes stale handles.
- **Konva z-index is render order** — In react-konva, rendering order in JSX determines z-index. To reorder layers, reorder the objects array in state. Don't use Konva's imperative `moveToTop()/moveToBottom()` — let React's declarative rendering handle it.
- **Artboard clip region** — If objects extend beyond the artboard, they should still be visible during design but clipped on export. Use Konva `Group` with `clipFunc` for the artboard boundary, or simply render the artboard outline without clipping during design.
## Open Risks
- **Canvas mock stability in Vitest**`vitest-canvas-mock` or `jest-canvas-mock` may not fully cover Konva's canvas usage. If canvas mocking proves unreliable, fall back to testing only pure state/logic functions and defer Konva component tests to integration testing. This affects test coverage but not functionality.
- **Performance with many objects** — A design with 50+ objects plus an imported SVG image could slow down Konva rendering. Mitigate by using `listening: false` on non-interactive elements (artboard background), and `perfectDrawEnabled: false` for complex shapes. Not expected to be an issue for typical sign/patch designs (5-20 objects).
- **Shield/pennant artboard shapes** — These are custom clip paths. Need to define SVG path data that looks right at arbitrary aspect ratios. Shield is straightforward (symmetrical arch-top shape). Pennant is a triangle/banner shape. If aspect ratio distortion is problematic, constrain proportions.
## Sources
- Konva.js Transformer + multi-select: official docs (context7: /konvajs/site) — selection rectangle, shift-click, `tr.nodes([...])`
- Konva.js undo/redo: official docs (context7: /konvajs/site) — `useRef` history pattern with step pointer
- react-konva Transformer component: official docs (context7: /konvajs/react-konva) — `onTransformEnd` scale reset pattern
- Konva.js dash arrays for line styles: official docs — `line.dash([10, 5])` for dashed, `[10, 20, 0.001, 20]` for dot-dash
- Konva.js SVG on canvas: official docs — `Konva.Image.fromURL()` for SVG rendering, `Konva.Path` for individual SVG paths
- Konva.js layering: official docs — render order in JSX determines z-index, `zIndex()` for imperative control

View file

@ -0,0 +1,39 @@
---
estimated_steps: 4
estimated_files: 10
skills_used: []
---
# T01: Canvas types, state hook with undo/redo, artboard setup, and test infrastructure
Establish the foundation that every other task depends on: canvas type definitions (discriminated union for canvas objects, artboard config, canvas state), the central useCanvasState hook with full CRUD + selection + undo/redo, artboard shape utilities, the ArtboardSetup component, and canvas mock setup for Vitest. This is the riskiest piece — the state model shape affects every downstream component.
The useCanvasState hook is a useReducer + useRef pattern: current state in a reducer (triggers re-renders), history stack in a useRef (avoids re-renders on history push). Every mutation dispatches through the reducer which also pushes a history snapshot. Undo/redo pops from history.
The ArtboardSetup component renders a modal overlay on first entering the canvas view with shape picker (rect, square, circle, oval, shield, pennant, custom), width/height number inputs, and inches/mm units toggle. On confirm, it produces an ArtboardConfig that initializes the canvas.
Install konva, react-konva, and jest-canvas-mock as dependencies. Set up canvas mock in test-setup.ts so Konva components don't crash in jsdom.
## Inputs
- ``app/src/types/engine.ts` — TraceMetadata interface consumed by canvas state`
- ``app/src/index.css` — CSS variables for ArtboardSetup component styling`
- ``app/src/test-setup.ts` — existing test setup to extend with canvas mock`
- ``app/package.json` — existing dependencies to extend with konva/react-konva/jest-canvas-mock`
## Expected Output
- ``app/src/types/canvas.ts` — CanvasObject discriminated union, ArtboardConfig, CanvasState types`
- ``app/src/hooks/useCanvasState.ts` — Central state hook: addObject, removeObject, updateObject, selectObjects, deselectAll, reorderObject, toggleVisibility, toggleLock, undo, redo, setArtboard`
- ``app/src/hooks/__tests__/useCanvasState.test.ts` — Unit tests for all state transitions: add, remove, update, select, deselect, reorder, undo, redo`
- ``app/src/utils/artboardShapes.ts` — Artboard shape path data (shield, pennant) and dimension presets`
- ``app/src/utils/__tests__/artboardShapes.test.ts` — Tests for artboard shape path generation and presets`
- ``app/src/utils/alignment.ts` — Pure alignment functions: alignLeft/Center/Right/Top/Middle/Bottom, distributeH/V, centerOnArtboard`
- ``app/src/utils/__tests__/alignment.test.ts` — Tests for all alignment calculations`
- ``app/src/components/canvas/ArtboardSetup.tsx` — Artboard setup modal with shape picker, dimension inputs, units toggle`
- ``app/src/test-setup.ts` — Extended with jest-canvas-mock import`
- ``app/package.json` — Updated with konva, react-konva, jest-canvas-mock dependencies`
## Verification
cd app && npx vitest run --reporter=verbose && npx tsc --noEmit

View file

@ -0,0 +1,96 @@
---
id: T01
parent: S02
milestone: M002
provides: []
requires: []
affects: []
key_files: ["app/src/types/canvas.ts", "app/src/hooks/useCanvasState.ts", "app/src/hooks/__tests__/useCanvasState.test.ts", "app/src/utils/artboardShapes.ts", "app/src/utils/__tests__/artboardShapes.test.ts", "app/src/utils/alignment.ts", "app/src/utils/__tests__/alignment.test.ts", "app/src/components/canvas/ArtboardSetup.tsx", "app/src/test-setup.ts", "app/package.json"]
key_decisions: ["Used vitest-canvas-mock instead of jest-canvas-mock (jest globals unavailable in Vitest)", "select/deselect actions excluded from undo stack — selection is UI concern not content mutation", "History capped at 50 entries to bound memory", "useReducer + useRef pattern: reducer for renders, ref for undo/redo stacks"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Ran `cd app && npx vitest run --reporter=verbose` — 71 tests pass (6 test files, 0 failures). Ran `cd app && npx tsc --noEmit` — zero type errors under strict mode with noUnusedLocals/noUnusedParameters."
completed_at: 2026-03-26T05:31:55.492Z
blocker_discovered: false
---
# T01: Built canvas type system, useCanvasState hook with undo/redo, artboard shapes, alignment utils, ArtboardSetup component, and 48 new tests passing
> Built canvas type system, useCanvasState hook with undo/redo, artboard shapes, alignment utils, ArtboardSetup component, and 48 new tests passing
## What Happened
---
id: T01
parent: S02
milestone: M002
key_files:
- app/src/types/canvas.ts
- app/src/hooks/useCanvasState.ts
- app/src/hooks/__tests__/useCanvasState.test.ts
- app/src/utils/artboardShapes.ts
- app/src/utils/__tests__/artboardShapes.test.ts
- app/src/utils/alignment.ts
- app/src/utils/__tests__/alignment.test.ts
- app/src/components/canvas/ArtboardSetup.tsx
- app/src/test-setup.ts
- app/package.json
key_decisions:
- Used vitest-canvas-mock instead of jest-canvas-mock (jest globals unavailable in Vitest)
- select/deselect actions excluded from undo stack — selection is UI concern not content mutation
- History capped at 50 entries to bound memory
- useReducer + useRef pattern: reducer for renders, ref for undo/redo stacks
duration: ""
verification_result: passed
completed_at: 2026-03-26T05:31:55.507Z
blocker_discovered: false
---
# T01: Built canvas type system, useCanvasState hook with undo/redo, artboard shapes, alignment utils, ArtboardSetup component, and 48 new tests passing
**Built canvas type system, useCanvasState hook with undo/redo, artboard shapes, alignment utils, ArtboardSetup component, and 48 new tests passing**
## What Happened
Created the foundational layer for the design canvas (View 2): discriminated union CanvasObject type system with five variants (rect, circle, ellipse, line, image), the central useCanvasState hook using useReducer + useRef pattern for state + history separation, artboard shape path generators (shield, pennant) with dimension presets and unit conversion, 9 pure alignment/distribute functions, and the ArtboardSetup modal component. Installed konva, react-konva, and vitest-canvas-mock (substituted for jest-canvas-mock which crashes in Vitest). Extended test-setup.ts with canvas mock. All 71 tests pass (23 existing + 48 new) and TypeScript compiles clean.
## Verification
Ran `cd app && npx vitest run --reporter=verbose` — 71 tests pass (6 test files, 0 failures). Ran `cd app && npx tsc --noEmit` — zero type errors under strict mode with noUnusedLocals/noUnusedParameters.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2090ms |
| 2 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2100ms |
## Deviations
Used vitest-canvas-mock instead of jest-canvas-mock as specified in plan. jest-canvas-mock internally calls jest.fn() which doesn't exist in Vitest runtime, causing ReferenceError on all test suites.
## Known Issues
None.
## Files Created/Modified
- `app/src/types/canvas.ts`
- `app/src/hooks/useCanvasState.ts`
- `app/src/hooks/__tests__/useCanvasState.test.ts`
- `app/src/utils/artboardShapes.ts`
- `app/src/utils/__tests__/artboardShapes.test.ts`
- `app/src/utils/alignment.ts`
- `app/src/utils/__tests__/alignment.test.ts`
- `app/src/components/canvas/ArtboardSetup.tsx`
- `app/src/test-setup.ts`
- `app/package.json`
## Deviations
Used vitest-canvas-mock instead of jest-canvas-mock as specified in plan. jest-canvas-mock internally calls jest.fn() which doesn't exist in Vitest runtime, causing ReferenceError on all test suites.
## Known Issues
None.

View file

@ -0,0 +1,44 @@
---
estimated_steps: 12
estimated_files: 5
skills_used: []
---
# T02: Konva stage with artboard rendering, imported SVG, selection handles, and shape tools
Build the core Konva canvas rendering layer and wire it into the app. This task creates the KonvaStage component (Konva Stage + Layer with artboard background, all canvas objects, Transformer for selection handles, rubber-band selection rectangle), the DesignCanvas view container, and shape creation tools.
Key implementation details:
- DesignCanvas is the View 2 container. Layout: top toolbar area, left canvas with KonvaStage, right panel area (wired in T03). Receives svgData and traceMetadata props from App.tsx.
- Wire DesignCanvas into App.tsx replacing the placeholder div. Remove underscore prefixes from _svgResult and _traceMetadata variables.
- KonvaStage renders: (1) artboard background shape using Konva.Rect/Path/Circle based on ArtboardConfig, (2) all canvas objects from useCanvasState mapped to Konva components, (3) Transformer attached to selected nodes, (4) rubber-band selection rectangle for multi-select.
- Imported SVG from View 1: convert SVG string → Blob URL → Image element → Konva.Image. Auto-add to canvas objects on mount if svgData is provided.
- Shape tools: rect, circle, ellipse, line creation. Click canvas in tool mode to place shape at click position with default dimensions. Tool state tracked in DesignCanvas.
- Selection: click shape to select (single), shift-click to add to selection, click empty area to deselect, rubber-band rectangle selects all intersecting shapes.
- Transformer: on onTransformEnd, reset scaleX/scaleY to 1 and multiply into width/height to keep state model clean.
- Line objects support dash arrays for style variants (solid, dashed, dotted).
- Canvas view should fill available width. Override #root width constraint for canvas view or make canvas flex within it.
IMPORTANT tsconfig constraints: noUnusedLocals and noUnusedParameters are strict. All variables must be used or prefixed with underscore.
## Inputs
- ``app/src/types/canvas.ts` — CanvasObject types, ArtboardConfig from T01`
- ``app/src/hooks/useCanvasState.ts` — Central state hook from T01`
- ``app/src/utils/artboardShapes.ts` — Artboard shape paths from T01`
- ``app/src/App.tsx` — ViewState routing with svgResult/traceMetadata state`
- ``app/src/types/engine.ts` — TraceMetadata interface`
- ``app/src/index.css` — CSS variables`
- ``app/src/App.css` — Global shared styles`
## Expected Output
- ``app/src/views/DesignCanvas.tsx` — View 2 container with layout, toolbar area, KonvaStage, panel area, tool state management, imported SVG loading`
- ``app/src/views/DesignCanvas.module.css` — CSS module for canvas view layout (three-column: toolbar top, canvas left, panel right)`
- ``app/src/components/canvas/KonvaStage.tsx` — Konva Stage+Layer rendering artboard, objects, Transformer, rubber-band selection, shape creation, click-to-select`
- ``app/src/App.tsx` — Updated to render DesignCanvas with svgResult/traceMetadata props (underscores removed from variable names)`
- ``app/src/App.css` — Additional styles for canvas toolbar buttons and layout`
## Verification
cd app && npx tsc --noEmit && npx vitest run --reporter=verbose

View file

@ -0,0 +1,63 @@
---
estimated_steps: 26
estimated_files: 7
skills_used: []
---
# T03: Object panel, alignment bar, canvas toolbar, and shape properties panel
Build the UI panels that surround the canvas: the right-side ObjectPanel for layer management, the AlignmentBar for spatial arrangement, the CanvasToolbar for tool switching and canvas controls, and the ShapeProperties panel for editing selected objects.
ObjectPanel:
- Lists canvas objects by z-order (top of list = frontmost layer)
- Each row shows: drag handle, object type icon, name (double-click to rename), visibility toggle (eye icon), lock toggle (lock icon)
- Click row to select object on canvas, shift-click for multi-select
- Drag-to-reorder changes z-order in state (which changes render order in KonvaStage)
- Wire to useCanvasState: reorderObject, toggleVisibility, toggleLock, selectObjects
AlignmentBar:
- Appears when 1+ objects selected
- Buttons: align left, align center, align right, align top, align middle, align bottom
- When 2+ selected: distribute horizontally, distribute vertically
- Center on artboard button (works with 1+ selected)
- Consumes alignment utility functions from utils/alignment.ts
- Dispatches batch updateObject calls through useCanvasState
CanvasToolbar:
- Tool buttons: pointer/select (default), rectangle, circle, ellipse, line
- Active tool highlighted with accent color
- Undo/redo buttons (disabled when stack empty) — connected to useCanvasState undo/redo
- Grid toggle button
- Zoom controls (zoom in, zoom out, fit to artboard)
ShapeProperties:
- Shown when exactly 1 shape selected
- Fields: stroke color (color input), stroke weight (number input), fill color (color input with toggle), width/height display
- For line objects: line style dropdown (solid, dashed, dotted)
- Changes dispatch updateObject through useCanvasState
Wire all panels into DesignCanvas.tsx layout slots.
## Inputs
- ``app/src/types/canvas.ts` — CanvasObject types from T01`
- ``app/src/hooks/useCanvasState.ts` — State hook API from T01`
- ``app/src/utils/alignment.ts` — Alignment calculation functions from T01`
- ``app/src/views/DesignCanvas.tsx` — View 2 container with panel layout slots from T02`
- ``app/src/views/DesignCanvas.module.css` — Canvas layout CSS from T02`
- ``app/src/index.css` — CSS variables`
- ``app/src/App.css` — Global shared styles from T02`
## Expected Output
- ``app/src/components/canvas/ObjectPanel.tsx` — Layer list with reorder, visibility, lock, select, rename`
- ``app/src/components/canvas/AlignmentBar.tsx` — Alignment and distribution buttons wired to state`
- ``app/src/components/canvas/CanvasToolbar.tsx` — Tool switcher, undo/redo, grid toggle, zoom controls`
- ``app/src/components/canvas/ShapeProperties.tsx` — Property editor for selected shape (stroke, fill, dimensions, line style)`
- ``app/src/views/DesignCanvas.tsx` — Updated with all panels wired into layout`
- ``app/src/views/DesignCanvas.module.css` — Updated with panel layout refinements`
- ``app/src/App.css` — Updated with panel and toolbar component styles`
## Verification
cd app && npx tsc --noEmit && npx vitest run --reporter=verbose

View file

@ -0,0 +1,44 @@
---
estimated_steps: 16
estimated_files: 2
skills_used: []
---
# T04: Keyboard shortcuts, final integration wiring, and verification
Wire keyboard shortcuts for the canvas, perform final integration checks, and ensure all tests pass.
Keyboard shortcuts:
- Ctrl+Z: undo (calls useCanvasState.undo)
- Ctrl+Shift+Z (or Ctrl+Y): redo (calls useCanvasState.redo)
- Delete/Backspace: remove selected objects (calls useCanvasState.removeObject for each selected ID)
- Escape: deselect all (calls useCanvasState.deselectAll)
- Ctrl+A: select all objects
Implement via useEffect with keydown listener on the DesignCanvas container div (or window). Ensure shortcuts only fire when canvas view is active and no text input is focused (check activeElement).
Final integration:
- Verify the full flow: View 1 → Use This → Artboard Setup → Canvas with imported SVG
- Verify undo/redo buttons in toolbar reflect stack state (disabled when empty)
- Verify all panel components render and dispatch correctly
- Run full test suite: all existing 23 tests + all new tests must pass
- Run TypeScript compiler: zero errors
- Ensure no unused imports/variables (strict tsconfig)
This task closes the slice by ensuring everything works together as an integrated whole.
## Inputs
- ``app/src/views/DesignCanvas.tsx` — Canvas view with all panels from T03`
- ``app/src/hooks/useCanvasState.ts` — State hook with undo/redo from T01`
- ``app/src/components/canvas/CanvasToolbar.tsx` — Toolbar with undo/redo buttons from T03`
- ``app/src/types/canvas.ts` — Canvas types from T01`
## Expected Output
- ``app/src/views/DesignCanvas.tsx` — Updated with keyboard shortcut handler (Ctrl+Z, Ctrl+Shift+Z, Delete, Escape, Ctrl+A)`
- ``app/src/App.css` — Final style adjustments if needed`
## Verification
cd app && npx vitest run --reporter=verbose && npx tsc --noEmit

File diff suppressed because one or more lines are too long

136
app/package-lock.json generated
View file

@ -8,8 +8,10 @@
"name": "app", "name": "app",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"konva": "^10.2.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"react-konva": "^19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@ -27,7 +29,8 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",
"vite": "^8.0.1", "vite": "^8.0.1",
"vitest": "^4.1.1" "vitest": "^4.1.1",
"vitest-canvas-mock": "^1.1.4"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@ -1249,7 +1252,6 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1265,6 +1267,15 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/react-reconciler": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz",
"integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.2", "version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
@ -1996,11 +2007,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cssfontparser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz",
"integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-urls": { "node_modules/data-urls": {
@ -2596,6 +2613,27 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/its-fine": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
"integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.9"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/its-fine/node_modules/@types/react-reconciler": {
"version": "0.28.9",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -2724,6 +2762,26 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/konva": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/konva/-/konva-10.2.3.tgz",
"integrity": "sha512-NDGeIxm2nsQcp6oqZKS9T764JEi53RpQvpUxV2EK7Awm49fwdd1+EB1Nq1nyspRc0hOAKyKssoTFvPaKwiSUog==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -3083,6 +3141,16 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/moo-color": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz",
"integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "^1.1.4"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3365,6 +3433,52 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/react-konva": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.3.tgz",
"integrity": "sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.33.0",
"its-fine": "^2.0.0",
"react-reconciler": "0.33.0",
"scheduler": "0.27.0"
},
"peerDependencies": {
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/react-reconciler": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -3949,6 +4063,20 @@
} }
} }
}, },
"node_modules/vitest-canvas-mock": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/vitest-canvas-mock/-/vitest-canvas-mock-1.1.4.tgz",
"integrity": "sha512-4boWHY+STwAxGl1+uwakNNoQky5EjPLC8HuponXNoAscYyT1h/F7RUvTkl4IyF/MiWr3V8Q626je3Iel3eArqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssfontparser": "^1.2.1",
"moo-color": "^1.0.3"
},
"peerDependencies": {
"vitest": "^3.0.0 || ^4.0.0"
}
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View file

@ -10,8 +10,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"konva": "^10.2.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"react-konva": "^19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@ -29,6 +31,7 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",
"vite": "^8.0.1", "vite": "^8.0.1",
"vitest": "^4.1.1" "vitest": "^4.1.1",
"vitest-canvas-mock": "^1.1.4"
} }
} }

View file

@ -0,0 +1,152 @@
/**
* ArtboardSetup modal overlay for configuring the artboard when entering
* the design canvas view.
*
* Props:
* - onConfirm(config: ArtboardConfig): called when the user clicks "Create"
*/
import { useState } from 'react';
import type { ArtboardConfig, ArtboardShape, ArtboardUnit } from '../../types/canvas';
import { ARTBOARD_PRESETS, artboardClipPath } from '../../utils/artboardShapes';
const SHAPES: ArtboardShape[] = [
'rect', 'square', 'circle', 'oval', 'shield', 'pennant', 'custom',
];
const SHAPE_LABELS: Record<ArtboardShape, string> = {
rect: 'Rectangle',
square: 'Square',
circle: 'Circle',
oval: 'Oval',
shield: 'Shield',
pennant: 'Pennant',
custom: 'Custom',
};
interface ArtboardSetupProps {
onConfirm: (config: ArtboardConfig) => void;
}
export default function ArtboardSetup({ onConfirm }: ArtboardSetupProps) {
const [shape, setShape] = useState<ArtboardShape>('rect');
const [width, setWidth] = useState(ARTBOARD_PRESETS.rect.width);
const [height, setHeight] = useState(ARTBOARD_PRESETS.rect.height);
const [unit, setUnit] = useState<ArtboardUnit>('inches');
const handleShapeChange = (s: ArtboardShape) => {
setShape(s);
const preset = ARTBOARD_PRESETS[s];
setWidth(preset.width);
setHeight(preset.height);
// Square and circle enforce equal dimensions
if (s === 'square' || s === 'circle') {
setHeight(preset.width);
}
};
const handleWidthChange = (v: number) => {
setWidth(v);
if (shape === 'square' || shape === 'circle') setHeight(v);
};
const handleHeightChange = (v: number) => {
if (shape === 'square' || shape === 'circle') return;
setHeight(v);
};
const handleConfirm = () => {
const config: ArtboardConfig = { shape, width, height, unit };
const clip = artboardClipPath(config);
if (clip) config.clipPath = clip;
onConfirm(config);
};
return (
<div className="artboard-setup-overlay" data-testid="artboard-setup">
<div className="artboard-setup-modal">
<h2>Set Up Artboard</h2>
{/* Shape picker */}
<fieldset className="artboard-setup-shapes">
<legend>Shape</legend>
<div className="artboard-shape-grid">
{SHAPES.map((s) => (
<button
key={s}
type="button"
className={`artboard-shape-btn${s === shape ? ' active' : ''}`}
onClick={() => handleShapeChange(s)}
aria-pressed={s === shape}
>
{SHAPE_LABELS[s]}
</button>
))}
</div>
</fieldset>
{/* Dimension inputs */}
<div className="artboard-setup-dimensions">
<label>
Width
<input
type="number"
min={0.5}
step={0.25}
value={width}
onChange={(e) => handleWidthChange(Number(e.target.value))}
data-testid="artboard-width"
/>
</label>
<label>
Height
<input
type="number"
min={0.5}
step={0.25}
value={height}
onChange={(e) => handleHeightChange(Number(e.target.value))}
disabled={shape === 'square' || shape === 'circle'}
data-testid="artboard-height"
/>
</label>
</div>
{/* Units toggle */}
<fieldset className="artboard-setup-units">
<legend>Units</legend>
<label>
<input
type="radio"
name="unit"
value="inches"
checked={unit === 'inches'}
onChange={() => setUnit('inches')}
/>
Inches
</label>
<label>
<input
type="radio"
name="unit"
value="mm"
checked={unit === 'mm'}
onChange={() => setUnit('mm')}
/>
Millimeters
</label>
</fieldset>
{/* Confirm */}
<button
type="button"
className="artboard-setup-confirm"
onClick={handleConfirm}
data-testid="artboard-confirm"
>
Create Artboard
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,241 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCanvasState } from '../useCanvasState';
import type { RectObject, CircleObject } from '../../types/canvas';
function makeRect(id: string, overrides: Partial<RectObject> = {}): RectObject {
return {
type: 'rect',
id,
name: `Rect ${id}`,
x: 0,
y: 0,
width: 100,
height: 50,
rotation: 0,
visible: true,
locked: false,
opacity: 1,
fill: '#fff',
stroke: '#000',
strokeWidth: 1,
...overrides,
};
}
function makeCircle(id: string): CircleObject {
return {
type: 'circle',
id,
name: `Circle ${id}`,
x: 50,
y: 50,
radius: 25,
rotation: 0,
visible: true,
locked: false,
opacity: 1,
fill: '#fff',
stroke: '#000',
strokeWidth: 1,
};
}
describe('useCanvasState', () => {
it('starts with empty objects and no artboard', () => {
const { result } = renderHook(() => useCanvasState());
expect(result.current.state.objects).toEqual([]);
expect(result.current.state.selectedIds).toEqual([]);
expect(result.current.state.artboard).toBeNull();
});
describe('addObject', () => {
it('adds an object to the canvas', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
expect(result.current.state.objects).toHaveLength(1);
expect(result.current.state.objects[0].id).toBe('r1');
});
});
describe('removeObject', () => {
it('removes an object by id', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
act(() => result.current.addObject(makeRect('r2')));
act(() => result.current.removeObject('r1'));
expect(result.current.state.objects).toHaveLength(1);
expect(result.current.state.objects[0].id).toBe('r2');
});
it('also removes the id from selectedIds', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
act(() => result.current.selectObjects(['r1']));
act(() => result.current.removeObject('r1'));
expect(result.current.state.selectedIds).toEqual([]);
});
});
describe('updateObject', () => {
it('merges changes into an existing object', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
act(() => result.current.updateObject('r1', { x: 42, y: 99 }));
expect(result.current.state.objects[0].x).toBe(42);
expect(result.current.state.objects[0].y).toBe(99);
});
});
describe('selectObjects / deselectAll', () => {
it('sets selected IDs', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
act(() => result.current.addObject(makeRect('r2')));
act(() => result.current.selectObjects(['r1', 'r2']));
expect(result.current.state.selectedIds).toEqual(['r1', 'r2']);
});
it('deselects all', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.selectObjects(['r1']));
act(() => result.current.deselectAll());
expect(result.current.state.selectedIds).toEqual([]);
});
});
describe('reorderObject', () => {
it('moves an object to a new z-index', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('a')));
act(() => result.current.addObject(makeRect('b')));
act(() => result.current.addObject(makeCircle('c')));
// Move 'a' to the end (topmost)
act(() => result.current.reorderObject('a', 2));
expect(result.current.state.objects.map((o) => o.id)).toEqual([
'b', 'c', 'a',
]);
});
it('clamps toIndex to valid range', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('a')));
act(() => result.current.addObject(makeRect('b')));
act(() => result.current.reorderObject('a', 999));
expect(result.current.state.objects.map((o) => o.id)).toEqual(['b', 'a']);
});
it('no-ops for unknown id', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('a')));
act(() => result.current.reorderObject('nope', 0));
expect(result.current.state.objects).toHaveLength(1);
});
});
describe('toggleVisibility / toggleLock', () => {
it('toggles visibility', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
expect(result.current.state.objects[0].visible).toBe(true);
act(() => result.current.toggleVisibility('r1'));
expect(result.current.state.objects[0].visible).toBe(false);
act(() => result.current.toggleVisibility('r1'));
expect(result.current.state.objects[0].visible).toBe(true);
});
it('toggles lock', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
expect(result.current.state.objects[0].locked).toBe(false);
act(() => result.current.toggleLock('r1'));
expect(result.current.state.objects[0].locked).toBe(true);
});
});
describe('setArtboard', () => {
it('sets the artboard config', () => {
const { result } = renderHook(() => useCanvasState());
act(() =>
result.current.setArtboard({
shape: 'rect',
width: 4,
height: 6,
unit: 'inches',
}),
);
expect(result.current.state.artboard).toEqual({
shape: 'rect',
width: 4,
height: 6,
unit: 'inches',
});
});
});
describe('undo / redo', () => {
it('undoes the last mutation', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
expect(result.current.state.objects).toHaveLength(1);
act(() => result.current.undo());
expect(result.current.state.objects).toHaveLength(0);
});
it('redoes after undo', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
act(() => result.current.undo());
expect(result.current.state.objects).toHaveLength(0);
act(() => result.current.redo());
expect(result.current.state.objects).toHaveLength(1);
});
it('undo is no-op when stack is empty', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.undo()); // should not throw
expect(result.current.state.objects).toHaveLength(0);
});
it('redo is no-op when stack is empty', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.redo()); // should not throw
expect(result.current.state.objects).toHaveLength(0);
});
it('new mutation clears the redo stack', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
act(() => result.current.undo());
// Now add a different object — redo should be gone
act(() => result.current.addObject(makeCircle('c1')));
act(() => result.current.redo()); // no-op
expect(result.current.state.objects).toHaveLength(1);
expect(result.current.state.objects[0].id).toBe('c1');
});
it('undoes through multiple mutations', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
act(() => result.current.addObject(makeRect('r2')));
act(() => result.current.addObject(makeRect('r3')));
expect(result.current.state.objects).toHaveLength(3);
act(() => result.current.undo());
expect(result.current.state.objects).toHaveLength(2);
act(() => result.current.undo());
expect(result.current.state.objects).toHaveLength(1);
act(() => result.current.undo());
expect(result.current.state.objects).toHaveLength(0);
});
it('select/deselect do not push to undo stack', () => {
const { result } = renderHook(() => useCanvasState());
act(() => result.current.addObject(makeRect('r1')));
act(() => result.current.selectObjects(['r1']));
act(() => result.current.deselectAll());
// undo should revert addObject, not select/deselect
act(() => result.current.undo());
expect(result.current.state.objects).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,256 @@
/**
* Central canvas state hook.
*
* Architecture:
* - Current state lives in a useReducer (triggers re-renders on mutation).
* - History (for undo / redo) lives in a useRef (never triggers re-renders).
* - Every mutation dispatches through the reducer, which also pushes a snapshot
* onto the undo stack.
*/
import { useCallback, useReducer, useRef } from 'react';
import type {
ArtboardConfig,
CanvasObject,
CanvasState,
} from '../types/canvas';
import type { TraceMetadata } from '../types/engine';
// -- Action types -------------------------------------------------------------
type CanvasAction =
| { type: 'ADD_OBJECT'; object: CanvasObject }
| { type: 'REMOVE_OBJECT'; id: string }
| { type: 'UPDATE_OBJECT'; id: string; changes: Partial<CanvasObject> }
| { type: 'SELECT_OBJECTS'; ids: string[] }
| { type: 'DESELECT_ALL' }
| { type: 'REORDER_OBJECT'; id: string; toIndex: number }
| { type: 'TOGGLE_VISIBILITY'; id: string }
| { type: 'TOGGLE_LOCK'; id: string }
| { type: 'SET_ARTBOARD'; config: ArtboardConfig }
| { type: 'RESTORE'; state: CanvasState };
// -- Reducer ------------------------------------------------------------------
function canvasReducer(state: CanvasState, action: CanvasAction): CanvasState {
switch (action.type) {
case 'ADD_OBJECT':
return { ...state, objects: [...state.objects, action.object] };
case 'REMOVE_OBJECT':
return {
...state,
objects: state.objects.filter((o) => o.id !== action.id),
selectedIds: state.selectedIds.filter((sid) => sid !== action.id),
};
case 'UPDATE_OBJECT':
return {
...state,
objects: state.objects.map((o) =>
o.id === action.id ? ({ ...o, ...action.changes } as CanvasObject) : o,
),
};
case 'SELECT_OBJECTS':
return { ...state, selectedIds: action.ids };
case 'DESELECT_ALL':
return { ...state, selectedIds: [] };
case 'REORDER_OBJECT': {
const idx = state.objects.findIndex((o) => o.id === action.id);
if (idx === -1) return state;
const next = [...state.objects];
const [item] = next.splice(idx, 1);
const clampedIndex = Math.max(0, Math.min(action.toIndex, next.length));
next.splice(clampedIndex, 0, item);
return { ...state, objects: next };
}
case 'TOGGLE_VISIBILITY':
return {
...state,
objects: state.objects.map((o) =>
o.id === action.id ? ({ ...o, visible: !o.visible } as CanvasObject) : o,
),
};
case 'TOGGLE_LOCK':
return {
...state,
objects: state.objects.map((o) =>
o.id === action.id ? ({ ...o, locked: !o.locked } as CanvasObject) : o,
),
};
case 'SET_ARTBOARD':
return { ...state, artboard: action.config };
case 'RESTORE':
return action.state;
default:
return state;
}
}
// -- History ref type ---------------------------------------------------------
interface HistoryRef {
undoStack: CanvasState[];
redoStack: CanvasState[];
}
const MAX_HISTORY = 50;
// -- Hook ---------------------------------------------------------------------
export interface UseCanvasStateReturn {
state: CanvasState;
addObject: (object: CanvasObject) => void;
removeObject: (id: string) => void;
updateObject: (id: string, changes: Partial<CanvasObject>) => void;
selectObjects: (ids: string[]) => void;
deselectAll: () => void;
reorderObject: (id: string, toIndex: number) => void;
toggleVisibility: (id: string) => void;
toggleLock: (id: string) => void;
setArtboard: (config: ArtboardConfig) => void;
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
}
export function useCanvasState(
traceMetadata: TraceMetadata | null = null,
): UseCanvasStateReturn {
const initialState: CanvasState = {
objects: [],
selectedIds: [],
artboard: null,
traceMetadata,
};
const [state, dispatch] = useReducer(canvasReducer, initialState);
const historyRef = useRef<HistoryRef>({ undoStack: [], redoStack: [] });
// Push a snapshot before every mutation
const pushHistory = useCallback(
(currentState: CanvasState) => {
const h = historyRef.current;
h.undoStack = [...h.undoStack.slice(-(MAX_HISTORY - 1)), currentState];
h.redoStack = []; // clear redo on new action
},
[],
);
// -- Mutators (each pushes history then dispatches) -----------------------
const addObject = useCallback(
(object: CanvasObject) => {
pushHistory(state);
dispatch({ type: 'ADD_OBJECT', object });
},
[state, pushHistory],
);
const removeObject = useCallback(
(id: string) => {
pushHistory(state);
dispatch({ type: 'REMOVE_OBJECT', id });
},
[state, pushHistory],
);
const updateObject = useCallback(
(id: string, changes: Partial<CanvasObject>) => {
pushHistory(state);
dispatch({ type: 'UPDATE_OBJECT', id, changes });
},
[state, pushHistory],
);
const selectObjects = useCallback(
(ids: string[]) => {
dispatch({ type: 'SELECT_OBJECTS', ids });
},
[],
);
const deselectAll = useCallback(() => {
dispatch({ type: 'DESELECT_ALL' });
}, []);
const reorderObject = useCallback(
(id: string, toIndex: number) => {
pushHistory(state);
dispatch({ type: 'REORDER_OBJECT', id, toIndex });
},
[state, pushHistory],
);
const toggleVisibility = useCallback(
(id: string) => {
pushHistory(state);
dispatch({ type: 'TOGGLE_VISIBILITY', id });
},
[state, pushHistory],
);
const toggleLock = useCallback(
(id: string) => {
pushHistory(state);
dispatch({ type: 'TOGGLE_LOCK', id });
},
[state, pushHistory],
);
const setArtboard = useCallback(
(config: ArtboardConfig) => {
pushHistory(state);
dispatch({ type: 'SET_ARTBOARD', config });
},
[state, pushHistory],
);
// -- Undo / Redo ----------------------------------------------------------
const undo = useCallback(() => {
const h = historyRef.current;
if (h.undoStack.length === 0) return;
const prev = h.undoStack[h.undoStack.length - 1];
h.undoStack = h.undoStack.slice(0, -1);
h.redoStack = [...h.redoStack, state];
dispatch({ type: 'RESTORE', state: prev });
}, [state]);
const redo = useCallback(() => {
const h = historyRef.current;
if (h.redoStack.length === 0) return;
const next = h.redoStack[h.redoStack.length - 1];
h.redoStack = h.redoStack.slice(0, -1);
h.undoStack = [...h.undoStack, state];
dispatch({ type: 'RESTORE', state: next });
}, [state]);
return {
state,
addObject,
removeObject,
updateObject,
selectObjects,
deselectAll,
reorderObject,
toggleVisibility,
toggleLock,
setArtboard,
undo,
redo,
canUndo: historyRef.current.undoStack.length > 0,
canRedo: historyRef.current.redoStack.length > 0,
};
}

View file

@ -1 +1,2 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import 'vitest-canvas-mock';

101
app/src/types/canvas.ts Normal file
View file

@ -0,0 +1,101 @@
/** Canvas object types, artboard configuration, and canvas state. */
import type { TraceMetadata } from './engine';
// -- Artboard --
export type ArtboardShape =
| 'rect'
| 'square'
| 'circle'
| 'oval'
| 'shield'
| 'pennant'
| 'custom';
export type ArtboardUnit = 'inches' | 'mm';
export interface ArtboardConfig {
shape: ArtboardShape;
width: number; // in current unit
height: number; // in current unit
unit: ArtboardUnit;
/** Custom clip path data (SVG path "d" attr), used only for shield/pennant/custom. */
clipPath?: string;
}
// -- Canvas Objects (discriminated union) --
interface BaseCanvasObject {
id: string;
name: string;
x: number;
y: number;
rotation: number;
visible: boolean;
locked: boolean;
opacity: number;
}
export interface RectObject extends BaseCanvasObject {
type: 'rect';
width: number;
height: number;
fill: string;
stroke: string;
strokeWidth: number;
}
export interface CircleObject extends BaseCanvasObject {
type: 'circle';
radius: number;
fill: string;
stroke: string;
strokeWidth: number;
}
export interface EllipseObject extends BaseCanvasObject {
type: 'ellipse';
radiusX: number;
radiusY: number;
fill: string;
stroke: string;
strokeWidth: number;
}
export type LineStyle = 'solid' | 'dashed' | 'dotted';
export interface LineObject extends BaseCanvasObject {
type: 'line';
points: number[]; // [x1, y1, x2, y2, ...]
stroke: string;
strokeWidth: number;
lineStyle: LineStyle;
dash: number[];
}
export interface ImageObject extends BaseCanvasObject {
type: 'image';
width: number;
height: number;
/** Blob URL or data URL for the image source. */
src: string;
}
export type CanvasObject =
| RectObject
| CircleObject
| EllipseObject
| LineObject
| ImageObject;
export type CanvasObjectType = CanvasObject['type'];
// -- Canvas State --
export interface CanvasState {
objects: CanvasObject[];
selectedIds: string[];
artboard: ArtboardConfig | null;
traceMetadata: TraceMetadata | null;
}

View file

@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest';
import {
alignLeft,
alignCenter,
alignRight,
alignTop,
alignMiddle,
alignBottom,
distributeHorizontal,
distributeVertical,
centerOnArtboard,
} from '../alignment';
import type { BoundingRect } from '../alignment';
const items: BoundingRect[] = [
{ id: 'a', x: 10, y: 20, width: 30, height: 40 },
{ id: 'b', x: 50, y: 60, width: 20, height: 10 },
{ id: 'c', x: 100, y: 10, width: 40, height: 50 },
];
describe('alignment', () => {
describe('alignLeft', () => {
it('moves all items to the leftmost x', () => {
const result = alignLeft(items);
expect(result.every((r) => r.x === 10)).toBe(true);
});
it('preserves y positions', () => {
const result = alignLeft(items);
expect(result.map((r) => r.y)).toEqual([20, 60, 10]);
});
});
describe('alignCenter', () => {
it('centers all items on the average horizontal center', () => {
const result = alignCenter(items);
const centers = result.map((r, i) => r.x + items[i].width / 2);
expect(centers[0]).toBeCloseTo(centers[1], 5);
expect(centers[1]).toBeCloseTo(centers[2], 5);
});
});
describe('alignRight', () => {
it('aligns all items to the rightmost edge', () => {
const result = alignRight(items);
const maxRight = 100 + 40; // item c right edge
for (let i = 0; i < result.length; i++) {
expect(result[i].x + items[i].width).toBeCloseTo(maxRight, 5);
}
});
});
describe('alignTop', () => {
it('moves all items to the topmost y', () => {
const result = alignTop(items);
expect(result.every((r) => r.y === 10)).toBe(true);
});
});
describe('alignMiddle', () => {
it('centers all items on the average vertical middle', () => {
const result = alignMiddle(items);
const middles = result.map((r, i) => r.y + items[i].height / 2);
expect(middles[0]).toBeCloseTo(middles[1], 5);
expect(middles[1]).toBeCloseTo(middles[2], 5);
});
});
describe('alignBottom', () => {
it('aligns all items to the bottommost edge', () => {
const result = alignBottom(items);
// Max bottom edge: max of (20+40=60, 60+10=70, 10+50=60) = 70
const maxBot = Math.max(...items.map((i) => i.y + i.height));
for (let i = 0; i < result.length; i++) {
expect(result[i].y + items[i].height).toBeCloseTo(maxBot, 5);
}
});
});
describe('distributeHorizontal', () => {
it('returns same positions for fewer than 3 items', () => {
const two = items.slice(0, 2);
const result = distributeHorizontal(two);
expect(result[0].x).toBe(two[0].x);
expect(result[1].x).toBe(two[1].x);
});
it('evenly spaces 3+ items horizontally', () => {
const result = distributeHorizontal(items);
// sorted by x: a(10), b(50), c(100)
// gap should be equal between all items
const sorted = [...result].sort((a, b) => a.x - b.x);
const gap1 = sorted[1].x - (sorted[0].x + 30); // 30=width of a
const gap2 = sorted[2].x - (sorted[1].x + 20); // 20=width of b
expect(gap1).toBeCloseTo(gap2, 5);
});
});
describe('distributeVertical', () => {
it('returns same positions for fewer than 3 items', () => {
const two = items.slice(0, 2);
const result = distributeVertical(two);
expect(result[0].y).toBe(two[0].y);
expect(result[1].y).toBe(two[1].y);
});
it('evenly spaces 3+ items vertically', () => {
const result = distributeVertical(items);
const sorted = [...result].sort((a, b) => a.y - b.y);
// sorted by y: c(10,h=50), a(20,h=40), b(60,h=10)
const gap1 = sorted[1].y - (sorted[0].y + 50); // 50=height of c
const gap2 = sorted[2].y - (sorted[1].y + 40); // 40=height of a
expect(gap1).toBeCloseTo(gap2, 5);
});
});
describe('centerOnArtboard', () => {
it('returns empty array for empty input', () => {
expect(centerOnArtboard([], 400, 400)).toEqual([]);
});
it('centers a single item on the artboard', () => {
const single: BoundingRect[] = [
{ id: 'x', x: 0, y: 0, width: 100, height: 100 },
];
const result = centerOnArtboard(single, 400, 400);
expect(result[0].x).toBe(150);
expect(result[0].y).toBe(150);
});
it('centers a group on the artboard', () => {
const group: BoundingRect[] = [
{ id: 'a', x: 0, y: 0, width: 50, height: 50 },
{ id: 'b', x: 50, y: 0, width: 50, height: 50 },
];
const result = centerOnArtboard(group, 400, 400);
// Group width = 100, centered at (150, 175)
const groupLeft = Math.min(result[0].x, result[1].x);
const groupRight = Math.max(result[0].x + 50, result[1].x + 50);
expect((groupLeft + groupRight) / 2).toBeCloseTo(200, 5);
});
});
});

View file

@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import {
shieldPath,
pennantPath,
toPx,
fromPx,
artboardClipPath,
ARTBOARD_PRESETS,
} from '../artboardShapes';
import type { ArtboardConfig } from '../../types/canvas';
describe('artboardShapes', () => {
describe('ARTBOARD_PRESETS', () => {
it('has presets for all shapes', () => {
const shapes = ['rect', 'square', 'circle', 'oval', 'shield', 'pennant', 'custom'] as const;
for (const s of shapes) {
expect(ARTBOARD_PRESETS[s]).toBeDefined();
expect(ARTBOARD_PRESETS[s].width).toBeGreaterThan(0);
expect(ARTBOARD_PRESETS[s].height).toBeGreaterThan(0);
}
});
it('square preset has equal width and height', () => {
expect(ARTBOARD_PRESETS.square.width).toBe(ARTBOARD_PRESETS.square.height);
});
it('circle preset has equal width and height', () => {
expect(ARTBOARD_PRESETS.circle.width).toBe(ARTBOARD_PRESETS.circle.height);
});
});
describe('shieldPath', () => {
it('produces a closed SVG path', () => {
const p = shieldPath(100, 120);
expect(p).toContain('M');
expect(p).toContain('Z');
});
it('stays within bounding box', () => {
const p = shieldPath(200, 300);
// path starts at indent, ends at w-indent so all coords ≤ 200/300
expect(p).toBeTruthy();
});
});
describe('pennantPath', () => {
it('produces a triangular closed path', () => {
const p = pennantPath(100, 200);
expect(p).toBe('M 0 0 L 100 0 L 50 200 Z');
});
});
describe('toPx / fromPx', () => {
it('converts inches to pixels at 96 PPI', () => {
expect(toPx(1, 'inches')).toBe(96);
expect(toPx(2, 'inches')).toBe(192);
});
it('converts mm to pixels', () => {
const px = toPx(25.4, 'mm');
expect(px).toBeCloseTo(96, 1);
});
it('fromPx inverts toPx for inches', () => {
expect(fromPx(96, 'inches')).toBe(1);
});
it('fromPx inverts toPx for mm', () => {
expect(fromPx(toPx(10, 'mm'), 'mm')).toBeCloseTo(10, 5);
});
});
describe('artboardClipPath', () => {
it('returns undefined for rect', () => {
const cfg: ArtboardConfig = { shape: 'rect', width: 4, height: 6, unit: 'inches' };
expect(artboardClipPath(cfg)).toBeUndefined();
});
it('returns a path string for shield', () => {
const cfg: ArtboardConfig = { shape: 'shield', width: 4, height: 5, unit: 'inches' };
const p = artboardClipPath(cfg);
expect(p).toBeDefined();
expect(p).toContain('Z');
});
it('returns a triangular path for pennant', () => {
const cfg: ArtboardConfig = { shape: 'pennant', width: 3, height: 8, unit: 'inches' };
const p = artboardClipPath(cfg);
expect(p).toBeDefined();
expect(p).toContain('Z');
});
it('returns custom clipPath when shape is custom', () => {
const cfg: ArtboardConfig = {
shape: 'custom',
width: 4,
height: 4,
unit: 'inches',
clipPath: 'M 0 0 L 50 0 L 50 50 Z',
};
expect(artboardClipPath(cfg)).toBe('M 0 0 L 50 0 L 50 50 Z');
});
});
});

116
app/src/utils/alignment.ts Normal file
View file

@ -0,0 +1,116 @@
/**
* Pure alignment functions.
*
* Every function accepts a set of bounding rectangles and returns new (x, y)
* positions. The caller is responsible for dispatching updateObject calls.
*/
export interface BoundingRect {
id: string;
x: number;
y: number;
width: number;
height: number;
}
export interface PositionUpdate {
id: string;
x: number;
y: number;
}
// -- Align to group edge / center --------------------------------------------
export function alignLeft(items: BoundingRect[]): PositionUpdate[] {
const minX = Math.min(...items.map((i) => i.x));
return items.map((i) => ({ id: i.id, x: minX, y: i.y }));
}
export function alignCenter(items: BoundingRect[]): PositionUpdate[] {
const centers = items.map((i) => i.x + i.width / 2);
const avgCenter = centers.reduce((a, b) => a + b, 0) / centers.length;
return items.map((i) => ({ id: i.id, x: avgCenter - i.width / 2, y: i.y }));
}
export function alignRight(items: BoundingRect[]): PositionUpdate[] {
const maxRight = Math.max(...items.map((i) => i.x + i.width));
return items.map((i) => ({ id: i.id, x: maxRight - i.width, y: i.y }));
}
export function alignTop(items: BoundingRect[]): PositionUpdate[] {
const minY = Math.min(...items.map((i) => i.y));
return items.map((i) => ({ id: i.id, x: i.x, y: minY }));
}
export function alignMiddle(items: BoundingRect[]): PositionUpdate[] {
const middles = items.map((i) => i.y + i.height / 2);
const avgMiddle = middles.reduce((a, b) => a + b, 0) / middles.length;
return items.map((i) => ({ id: i.id, x: i.x, y: avgMiddle - i.height / 2 }));
}
export function alignBottom(items: BoundingRect[]): PositionUpdate[] {
const maxBottom = Math.max(...items.map((i) => i.y + i.height));
return items.map((i) => ({ id: i.id, x: i.x, y: maxBottom - i.height }));
}
// -- Distribute evenly -------------------------------------------------------
export function distributeHorizontal(items: BoundingRect[]): PositionUpdate[] {
if (items.length < 3) return items.map((i) => ({ id: i.id, x: i.x, y: i.y }));
const sorted = [...items].sort((a, b) => a.x - b.x);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalWidth = sorted.reduce((s, i) => s + i.width, 0);
const gap =
(last.x + last.width - first.x - totalWidth) / (sorted.length - 1);
let cursor = first.x;
return sorted.map((item) => {
const pos = { id: item.id, x: cursor, y: item.y };
cursor += item.width + gap;
return pos;
});
}
export function distributeVertical(items: BoundingRect[]): PositionUpdate[] {
if (items.length < 3) return items.map((i) => ({ id: i.id, x: i.x, y: i.y }));
const sorted = [...items].sort((a, b) => a.y - b.y);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalHeight = sorted.reduce((s, i) => s + i.height, 0);
const gap =
(last.y + last.height - first.y - totalHeight) / (sorted.length - 1);
let cursor = first.y;
return sorted.map((item) => {
const pos = { id: item.id, x: item.x, y: cursor };
cursor += item.height + gap;
return pos;
});
}
// -- Center on artboard ------------------------------------------------------
export function centerOnArtboard(
items: BoundingRect[],
artboardWidth: number,
artboardHeight: number,
): PositionUpdate[] {
if (items.length === 0) return [];
// Find bounding box of all items
const minX = Math.min(...items.map((i) => i.x));
const minY = Math.min(...items.map((i) => i.y));
const maxX = Math.max(...items.map((i) => i.x + i.width));
const maxY = Math.max(...items.map((i) => i.y + i.height));
const groupW = maxX - minX;
const groupH = maxY - minY;
const offsetX = (artboardWidth - groupW) / 2 - minX;
const offsetY = (artboardHeight - groupH) / 2 - minY;
return items.map((i) => ({
id: i.id,
x: i.x + offsetX,
y: i.y + offsetY,
}));
}

View file

@ -0,0 +1,96 @@
/**
* Artboard shape path data and dimension presets.
*
* Paths are expressed as SVG path "d" attribute strings, generated relative to
* a 0,0 origin for the given width × height. For standard shapes (rect,
* square, circle, oval) no clip path is needed Konva primitives handle them.
* Shield and pennant require custom clip paths.
*/
import type { ArtboardConfig, ArtboardShape, ArtboardUnit } from '../types/canvas';
// -- Dimension presets (inches) -----------------------------------------------
export interface DimensionPreset {
label: string;
width: number;
height: number;
unit: ArtboardUnit;
}
/** Default presets for each artboard shape. */
export const ARTBOARD_PRESETS: Record<ArtboardShape, DimensionPreset> = {
rect: { label: 'Rectangle', width: 4, height: 6, unit: 'inches' },
square: { label: 'Square', width: 4, height: 4, unit: 'inches' },
circle: { label: 'Circle', width: 4, height: 4, unit: 'inches' },
oval: { label: 'Oval', width: 3, height: 4, unit: 'inches' },
shield: { label: 'Shield', width: 4, height: 5, unit: 'inches' },
pennant: { label: 'Pennant', width: 3.5, height: 8, unit: 'inches' },
custom: { label: 'Custom', width: 4, height: 4, unit: 'inches' },
};
// -- Path generators ----------------------------------------------------------
/**
* Shield shape: flat top, slightly flared sides, pointed bottom.
* The path fills the full w × h bounding box.
*/
export function shieldPath(w: number, h: number): string {
const indent = w * 0.08;
return [
`M ${indent} 0`,
`L ${w - indent} 0`,
`Q ${w} 0, ${w} ${h * 0.08}`,
`L ${w} ${h * 0.5}`,
`Q ${w} ${h * 0.75}, ${w / 2} ${h}`,
`Q 0 ${h * 0.75}, 0 ${h * 0.5}`,
`L 0 ${h * 0.08}`,
`Q 0 0, ${indent} 0`,
'Z',
].join(' ');
}
/**
* Pennant / triangle shape: wide top tapering to a point at the bottom-center.
*/
export function pennantPath(w: number, h: number): string {
return `M 0 0 L ${w} 0 L ${w / 2} ${h} Z`;
}
/**
* Given an ArtboardConfig, return the SVG clip path string if the shape needs
* one, or undefined if standard Konva shapes suffice.
*/
export function artboardClipPath(config: ArtboardConfig): string | undefined {
const { shape, width, height } = config;
const pxW = toPx(width, config.unit);
const pxH = toPx(height, config.unit);
switch (shape) {
case 'shield':
return shieldPath(pxW, pxH);
case 'pennant':
return pennantPath(pxW, pxH);
case 'custom':
return config.clipPath;
default:
return undefined;
}
}
// -- Unit conversion ----------------------------------------------------------
/** Pixels-per-inch at screen DPI (96). */
const PPI = 96;
/** Pixels-per-mm. */
const PPMM = PPI / 25.4;
/** Convert a dimension to pixels. */
export function toPx(value: number, unit: ArtboardUnit): number {
return unit === 'inches' ? value * PPI : value * PPMM;
}
/** Convert pixels back to the given unit. */
export function fromPx(px: number, unit: ArtboardUnit): number {
return unit === 'inches' ? px / PPI : px / PPMM;
}