diff --git a/.gsd/DECISIONS.md b/.gsd/DECISIONS.md index eed3225..da10a42 100644 --- a/.gsd/DECISIONS.md +++ b/.gsd/DECISIONS.md @@ -12,3 +12,4 @@ | 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 | | 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 | +| D007 | | architecture | Canvas object type system design | Discriminated union on `type` field: 'rect' \| 'circle' \| 'ellipse' \| 'line' \| 'image'. All objects share BaseCanvasObject (id, name, x, y, visible, locked, stroke, fill, opacity). Type-specific fields via intersection types. | TypeScript discriminated unions enable exhaustive type narrowing in switch/if statements, catching missing cases at compile time. Shared base keeps CRUD operations generic while type-specific panels (ShapeProperties) safely narrow to access unique fields. 'text' type will be added in S03. | Yes | agent | diff --git a/.gsd/event-log.jsonl b/.gsd/event-log.jsonl index f8f9813..a593307 100644 --- a/.gsd/event-log.jsonl +++ b/.gsd/event-log.jsonl @@ -23,3 +23,6 @@ {"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T02"},"ts":"2026-03-26T05:36:12.635Z","actor":"agent","hash":"8dd660d191cc3758","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T03"},"ts":"2026-03-26T05:40:11.226Z","actor":"agent","hash":"0db7c0c1fa2fd555","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T04"},"ts":"2026-03-26T05:41:35.200Z","actor":"agent","hash":"eacbb47f931ba2af","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} +{"cmd":"complete-slice","params":{"milestoneId":"M002","sliceId":"S02"},"ts":"2026-03-26T05:44:01.083Z","actor":"agent","hash":"7c28ef1e308c7de7","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} +{"cmd":"plan-slice","params":{"milestoneId":"M002","sliceId":"S03"},"ts":"2026-03-26T05:48:40.020Z","actor":"agent","hash":"22ad4efa07f9be81","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} +{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S03","taskId":"T01"},"ts":"2026-03-26T05:52:47.302Z","actor":"agent","hash":"c631fa7e62a3f1cc","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} diff --git a/.gsd/milestones/M002/M002-ROADMAP.md b/.gsd/milestones/M002/M002-ROADMAP.md index 450f4ed..3a2217f 100644 --- a/.gsd/milestones/M002/M002-ROADMAP.md +++ b/.gsd/milestones/M002/M002-ROADMAP.md @@ -7,5 +7,5 @@ Build the React frontend with the Import & Convert view (View 1) and the Design | 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 | -| 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 | diff --git a/.gsd/milestones/M002/slices/S02/S02-SUMMARY.md b/.gsd/milestones/M002/slices/S02/S02-SUMMARY.md new file mode 100644 index 0000000..256f000 --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/S02-SUMMARY.md @@ -0,0 +1,128 @@ +--- +id: S02 +parent: M002 +milestone: M002 +provides: + - CanvasObject type system (types/canvas.ts) — discriminated union ready for 'text' type extension in S03 + - useCanvasState hook with full CRUD, selection, undo/redo, reorder, visibility/lock toggle + - KonvaStage component rendering artboard + all object types + selection/transform + - DesignCanvas view container wired into App.tsx with View 1 → View 2 data flow + - Artboard shape utilities (shield, pennant paths, presets, unit conversion) + - Alignment/distribute utility functions + - Canvas panel system (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) +requires: + [] +affects: + - S03 +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/components/canvas/KonvaStage.tsx + - 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.tsx + - app/src/App.css +key_decisions: + - D007: Canvas state uses useReducer + useRef pattern — reducer for renders, ref for undo/redo history. Select/deselect excluded from undo stack. History capped at 50. + - D008: Canvas objects use TypeScript discriminated union on type field (rect|circle|ellipse|line|image) with shared BaseCanvasObject. + - Artboard centered on stage via computed offsets; object positions stored relative to artboard origin + - SVG import from View 1 uses Blob URL → Image element → Konva.Image with auto-scale to fit artboard + - ObjectPanel displays objects in reverse z-order (frontmost at top) matching standard design tool UX + - Keyboard shortcuts use window-level keydown with activeElement tag guard to skip input/textarea/select +patterns_established: + - Discriminated union CanvasObject type system — add new object types by extending the union and adding case handling + - useCanvasState hook as single source of truth for all canvas mutations — all components dispatch through it + - Panel components receive state + dispatch callbacks from DesignCanvas — no direct hook usage in panels + - vitest-canvas-mock for Konva/canvas testing in Vitest (not jest-canvas-mock) +observability_surfaces: + - none +drill_down_paths: + - .gsd/milestones/M002/slices/S02/tasks/T01-SUMMARY.md + - .gsd/milestones/M002/slices/S02/tasks/T02-SUMMARY.md + - .gsd/milestones/M002/slices/S02/tasks/T03-SUMMARY.md + - .gsd/milestones/M002/slices/S02/tasks/T04-SUMMARY.md +duration: "" +verification_result: passed +completed_at: 2026-03-26T05:44:01.051Z +blocker_discovered: false +--- + +# S02: Design Canvas Core (View 2) + +**Built the Konva.js-powered 2D design canvas with artboard setup, shape tools, selection/transform, layer management, alignment, properties editing, keyboard shortcuts, and undo/redo — 48 new tests (71 total), zero TypeScript errors.** + +## What Happened + +This slice delivered the complete Design Canvas (View 2) for the Kerf Engine app across four tasks. + +**T01 — Foundation:** Established the canvas type system (`types/canvas.ts`) with a discriminated union for five object types (rect, circle, ellipse, line, image) sharing a `BaseCanvasObject`. Built the central `useCanvasState` hook using a useReducer + useRef pattern — reducer drives re-renders for current state, ref holds undo/redo history stacks without triggering renders. Select/deselect are excluded from the undo stack (UI-only concern). History is capped at 50 entries. Created artboard shape utilities (shield/pennant SVG paths, dimension presets, unit conversion), 9 pure alignment/distribute functions, and the `ArtboardSetup` modal component. Installed konva, react-konva, and vitest-canvas-mock. 48 new tests passing. + +**T02 — Rendering Layer:** Created `KonvaStage` rendering artboard backgrounds (Rect/Circle/Ellipse/Path based on shape config), mapping all canvas objects to Konva primitives with drag/transform handlers, Transformer synced to selected nodes, and rubber-band multi-select rectangle. Built `DesignCanvas` view container with ResizeObserver-driven sizing, artboard setup flow, and SVG import (Blob URL → Image element → Konva.Image with auto-scale). Wired into `App.tsx` to receive real svgData/traceMetadata props from View 1. + +**T03 — Panel System:** Built four panel components: `ObjectPanel` (layer list in reverse z-order with drag reorder, visibility/lock toggles, rename), `AlignmentBar` (6 alignment + 2 distribute + center-on-artboard), `CanvasToolbar` (tool switcher, undo/redo, grid toggle, zoom controls), and `ShapeProperties` (stroke/fill/dimensions/line-style editing with discriminated union narrowing). All wired to useCanvasState in DesignCanvas. + +**T04 — Shortcuts & Integration:** Added window-level keyboard shortcuts (Ctrl+Z undo, Ctrl+Shift+Z redo, Delete/Backspace remove, Escape deselect, Ctrl+A select all) with activeElement guard to skip input fields. Verified full integration: View 1 → Use This → Artboard Setup → Canvas with imported SVG. All 71 tests pass, zero TypeScript errors. + +## Verification + +Ran `cd app && npx vitest run --reporter=verbose` — 71 tests pass across 6 test files, 0 failures (exit 0). Ran `cd app && npx tsc --noEmit` — zero type errors under strict mode with noUnusedLocals/noUnusedParameters (exit 0). + +## Requirements Advanced + +None. + +## Requirements Validated + +None. + +## New Requirements Surfaced + +None. + +## Requirements Invalidated or Re-scoped + +None. + +## Deviations + +Used vitest-canvas-mock instead of jest-canvas-mock (plan specified jest-canvas-mock but it crashes in Vitest because it internally calls jest.fn()). This was identified in T01 and recorded as L010 in KNOWLEDGE.md. + +## Known Limitations + +Canvas components (KonvaStage, DesignCanvas panels) do not have dedicated unit tests — they were verified via TypeScript compilation and manual integration. Konva rendering in jsdom is limited since canvas operations are mocked. Drag-to-reorder in ObjectPanel uses simple index swapping, not full drag-and-drop library. + +## Follow-ups + +S03 will add 'text' type to the CanvasObject discriminated union and integrate opentype.js for font loading and text-to-path conversion. ShapeProperties panel will need a text-specific section. The canvas object type system (D008) was designed to accommodate this extension. + +## Files Created/Modified + +- `app/src/types/canvas.ts` — New — discriminated union type system for canvas objects (rect, circle, ellipse, line, image), ArtboardConfig, CanvasState, and CanvasAction types +- `app/src/hooks/useCanvasState.ts` — New — central state management hook with useReducer+useRef for CRUD, selection, undo/redo, reorder, visibility/lock +- `app/src/hooks/__tests__/useCanvasState.test.ts` — New — 20 tests covering all useCanvasState operations including undo/redo edge cases +- `app/src/utils/artboardShapes.ts` — New — artboard shape presets, shield/pennant SVG path generators, toPx/fromPx unit conversion, artboardClipPath +- `app/src/utils/__tests__/artboardShapes.test.ts` — New — 15 tests for artboard presets, path generation, unit conversion, clip paths +- `app/src/utils/alignment.ts` — New — 9 pure alignment/distribute/center functions for canvas objects +- `app/src/utils/__tests__/alignment.test.ts` — New — 13 tests for alignment, distribution, and center-on-artboard +- `app/src/components/canvas/ArtboardSetup.tsx` — New — modal for artboard shape/size/unit selection on entering canvas view +- `app/src/components/canvas/KonvaStage.tsx` — New — Konva Stage+Layer rendering artboard, all object types, Transformer, rubber-band selection +- `app/src/components/canvas/ObjectPanel.tsx` — New — layer management panel with z-order list, reorder, visibility/lock toggles, rename +- `app/src/components/canvas/AlignmentBar.tsx` — New — alignment and distribution toolbar consuming alignment utils +- `app/src/components/canvas/CanvasToolbar.tsx` — New — tool switcher, undo/redo, grid toggle, zoom controls +- `app/src/components/canvas/ShapeProperties.tsx` — New — property editor for selected shape (stroke, fill, dimensions, line style, opacity) +- `app/src/views/DesignCanvas.tsx` — New — View 2 container wiring KonvaStage + all panels to useCanvasState, SVG import, keyboard shortcuts +- `app/src/views/DesignCanvas.module.css` — New — CSS module for DesignCanvas layout +- `app/src/App.tsx` — Modified — wired DesignCanvas with svgData/traceMetadata props, removed underscore prefixes +- `app/src/App.css` — Modified — added comprehensive canvas UI styles (toolbar, panels, artboard setup, shape properties, alignment bar) +- `app/src/test-setup.ts` — Modified — added vitest-canvas-mock import for Konva testing +- `app/package.json` — Modified — added konva, react-konva, vitest-canvas-mock dependencies diff --git a/.gsd/milestones/M002/slices/S02/S02-UAT.md b/.gsd/milestones/M002/slices/S02/S02-UAT.md new file mode 100644 index 0000000..0610553 --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/S02-UAT.md @@ -0,0 +1,122 @@ +# S02: Design Canvas Core (View 2) — UAT + +**Milestone:** M002 +**Written:** 2026-03-26T05:44:01.052Z + +## UAT: Design Canvas Core (View 2) + +### Preconditions +- Engine API running at localhost:8000 +- App dev server running (`cd app && npm run dev`) +- A raster image (PNG/JPG) available for upload + +--- + +### Test 1: View 1 → View 2 Transition +1. Upload a raster image in View 1 +2. Wait for trace preview to render +3. Click "Use This" button +4. **Expected:** Artboard Setup modal appears with shape picker, dimension inputs, and unit toggle + +### Test 2: Artboard Setup — Shape Selection +1. In Artboard Setup modal, click each shape option: rect, square, circle, oval, shield, pennant +2. **Expected:** Shape preview updates for each selection. Square and circle auto-set equal width/height. Width/height inputs update with preset dimensions. +3. Toggle units between inches and mm +4. **Expected:** Dimension values convert correctly (e.g., 3in → 76.2mm) +5. Click "Create Artboard" +6. **Expected:** Modal closes, Konva canvas renders with artboard background matching chosen shape + +### Test 3: Shape Creation Tools +1. Click Rectangle tool in toolbar +2. Click on the canvas +3. **Expected:** A rectangle appears at the click position with default dimensions, black stroke, no fill +4. Repeat with Circle tool and Ellipse tool +5. **Expected:** Each shape appears correctly with default styling +6. Click Line tool and click on canvas +7. **Expected:** A horizontal line appears at the click position + +### Test 4: Object Selection & Transform +1. Click on a shape on the canvas +2. **Expected:** Selection handles (Transformer) appear around the shape. ObjectPanel highlights the selected row. +3. Drag the shape to a new position +4. **Expected:** Shape moves with cursor. Position updates in state. +5. Drag a transform handle to resize +6. **Expected:** Shape resizes. Width/height update (scaleX/scaleY reset to 1, dimensions multiplied). +7. Click empty canvas area +8. **Expected:** Shape deselected. Transformer hidden. ObjectPanel row unhighlighted. + +### Test 5: Multi-Select with Rubber Band +1. Create 3+ shapes on canvas +2. Click and drag on empty canvas area to draw a rubber-band rectangle over multiple shapes +3. **Expected:** All shapes intersecting the rectangle are selected. Transformer wraps all selected nodes. Multiple rows highlighted in ObjectPanel. +4. Shift-click an unselected shape +5. **Expected:** Shape added to selection without deselecting others. + +### Test 6: ObjectPanel — Layer Management +1. Create 3 shapes (they appear as layers in ObjectPanel in reverse z-order — newest on top) +2. **Expected:** ObjectPanel lists all 3 shapes with type icons and default names +3. Double-click a shape name in ObjectPanel +4. **Expected:** Name becomes editable. Type new name, press Enter — name persists. +5. Click the eye icon on a shape row +6. **Expected:** Shape becomes invisible on canvas. Eye icon toggles to "hidden" state. +7. Click the lock icon on a shape row +8. **Expected:** Shape can no longer be dragged or resized on canvas. + +### Test 7: Alignment Tools +1. Select 2+ shapes +2. Click "Align Left" in AlignmentBar +3. **Expected:** All selected shapes move so their left edges align with the leftmost shape's left edge +4. Click "Distribute Horizontally" (requires 3+ shapes selected) +5. **Expected:** Shapes evenly space horizontally +6. Select 1 shape, click "Center on Artboard" +7. **Expected:** Shape centers within the artboard bounds + +### Test 8: Shape Properties Panel +1. Select a single rectangle +2. **Expected:** ShapeProperties panel appears showing stroke color, stroke weight, fill color, width, height, opacity +3. Change stroke color using color input +4. **Expected:** Shape stroke updates immediately on canvas +5. Change fill color and enable fill toggle +6. **Expected:** Shape fill updates on canvas +7. Select a line object +8. **Expected:** ShapeProperties shows line style dropdown (solid, dashed, dotted). Changing style updates the line's dash array. + +### Test 9: Undo/Redo +1. Create a shape, move it, change its color (3 mutations) +2. Press Ctrl+Z three times +3. **Expected:** Each undo reverses one mutation in order: color reverts → position reverts → shape removed +4. Press Ctrl+Shift+Z (or Ctrl+Y) twice +5. **Expected:** Shape reappears, position restored +6. Click undo/redo buttons in CanvasToolbar +7. **Expected:** Same behavior as keyboard shortcuts. Buttons disabled when respective stack is empty. + +### Test 10: Keyboard Shortcuts +1. Select a shape, press Delete (or Backspace) +2. **Expected:** Shape removed from canvas and ObjectPanel +3. Create multiple shapes, press Ctrl+A +4. **Expected:** All shapes selected +5. Press Escape +6. **Expected:** All shapes deselected +7. Focus a text input (e.g., shape name rename in ObjectPanel), press Delete +8. **Expected:** Shortcut does NOT fire — normal text editing behavior occurs + +### Test 11: SVG Import from View 1 +1. Upload an image in View 1, complete tracing, click "Use This" +2. Set up artboard, confirm +3. **Expected:** Traced SVG appears as an image object on the canvas, auto-scaled to fit within artboard bounds. Object appears in ObjectPanel as an image type. + +### Test 12: Canvas Zoom & Grid +1. Click zoom-in button in CanvasToolbar +2. **Expected:** Canvas content appears larger +3. Click zoom-out button +4. **Expected:** Canvas content appears smaller +5. Click "Fit" button +6. **Expected:** Artboard fits within visible canvas area +7. Toggle grid button +8. **Expected:** Grid overlay toggles on/off on canvas + +### Edge Cases +- **Empty undo:** Pressing Ctrl+Z with no history should do nothing (no crash) +- **Delete with nothing selected:** Pressing Delete with no selection should do nothing +- **Rapid undo/redo:** Rapidly alternating Ctrl+Z and Ctrl+Shift+Z should not corrupt state +- **Window resize:** Canvas should resize responsively via ResizeObserver without layout breaks diff --git a/.gsd/milestones/M002/slices/S02/tasks/T04-VERIFY.json b/.gsd/milestones/M002/slices/S02/tasks/T04-VERIFY.json new file mode 100644 index 0000000..84a025e --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/tasks/T04-VERIFY.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "taskId": "T04", + "unitId": "M002/S02/T04", + "timestamp": 1774503701418, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd app", + "exitCode": 0, + "durationMs": 4, + "verdict": "pass" + }, + { + "command": "npx vitest run --reporter=verbose", + "exitCode": 1, + "durationMs": 1370, + "verdict": "fail" + }, + { + "command": "npx tsc --noEmit", + "exitCode": 1, + "durationMs": 730, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M002/slices/S03/S03-PLAN.md b/.gsd/milestones/M002/slices/S03/S03-PLAN.md index ededb0b..c8a56ca 100644 --- a/.gsd/milestones/M002/slices/S03/S03-PLAN.md +++ b/.gsd/milestones/M002/slices/S03/S03-PLAN.md @@ -1,6 +1,67 @@ # S03: Text System + Font Loading -**Goal:** Add text objects with opentype.js font rendering, font picker from fonts.json manifest, text-to-paths conversion +**Goal:** Add text objects to the design canvas with font loading from bundled fonts, property editing (font family, size, letter spacing, line height), and text-to-path conversion using opentype.js. **Demo:** After this: Add text objects, select fonts from picker, change size/spacing, convert text to paths — paths match original glyphs ## Tasks +- [x] **T01: Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests** — Create the fontService utility that loads .ttf fonts via fetch + opentype.parse(), caches parsed Font objects, and converts text to SVG path data. Bundle 3-5 OFL-licensed Google Fonts as .ttf files in app/public/fonts/. Add @font-face CSS declarations in App.css so Konva Text rendering matches the opentype.js fonts. Install opentype.js dependency. Write comprehensive unit tests for font loading, caching, and text-to-path conversion. + +This is the riskiest piece of the slice — if opentype.js path extraction doesn't produce accurate SVG `d` attributes compatible with Konva's Path component, everything downstream breaks. Testing with real bundled font files proves the integration. + +## Key Constraints +- opentype.js loads fonts from ArrayBuffer — must fetch() font files then call opentype.parse() +- Path.toPathData() needs consideration of coordinate systems (font Y-up vs canvas Y-down) +- Letter spacing is NOT built into getPath() — must implement per-character glyph positioning with manual x-advance +- Font files served from /fonts/ via Vite's public directory +- @font-face CSS must load before Konva renders Text nodes +- TypeScript strict mode with verbatimModuleSyntax — use `import type` for type-only imports +- Tests should mock fetch() to return real font file ArrayBuffers loaded from disk + - Estimate: 1h + - Files: app/src/utils/fontService.ts, app/src/utils/__tests__/fontService.test.ts, app/public/fonts/, app/src/App.css, app/package.json + - Verify: cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l +- [ ] **T02: Add TextObject type and wire text tool into canvas rendering** — Extend the CanvasObject discriminated union with a TextObject type. Wire text objects into all canvas components: KonvaStage (render via Konva Text + case in getObjWidth/getObjHeight + text tool creation in handleStageMouseDown), CanvasToolbar (add 'text' to CanvasTool union and TOOLS array), ObjectPanel (add 'text' icon to TYPE_ICONS), and DesignCanvas (pass through). This follows the exact pattern established in S02 for adding object types. + +## Key Implementation Details +- TextObject interface: extends BaseCanvasObject with type: 'text', text: string, fontFamily: string, fontSize: number, letterSpacing: number, lineHeight: number, fill: string, stroke: string, strokeWidth: number, width: number (for wrapping) +- CanvasTool union in KonvaStage.tsx must add 'text' — this is imported by CanvasToolbar +- KonvaStage renderObject switch: case 'text' renders from react-konva with fontFamily, fontSize, fill, stroke, strokeWidth, width, letterSpacing, lineHeight props +- KonvaStage handleStageMouseDown switch: case 'text' creates a TextObject with defaults (text: 'Text', fontFamily: 'Roboto', fontSize: 24, letterSpacing: 0, lineHeight: 1.2) +- KonvaStage getObjWidth for text: use obj.width (wrapping width) or estimate from fontSize * text.length * 0.6 +- KonvaStage getObjHeight for text: obj.fontSize * obj.lineHeight +- KonvaStage onTransformEnd for text: scale width, keep fontSize unchanged (text wraps to new width) +- ObjectPanel TYPE_ICONS: text → 'T' +- ShapeProperties getWidth/getHeight: add case 'text' returning obj.width and obj.fontSize * obj.lineHeight +- All switch statements must be exhaustive — TypeScript noFallthroughCasesInSwitch enforces this +- TypeScript strict mode — no unused locals/params, erasableSyntaxOnly, use import type + - Estimate: 1h + - Files: app/src/types/canvas.ts, app/src/components/canvas/KonvaStage.tsx, app/src/components/canvas/CanvasToolbar.tsx, app/src/components/canvas/ObjectPanel.tsx, app/src/components/canvas/ShapeProperties.tsx + - Verify: cd app && npx tsc --noEmit && npx vitest run --reporter=verbose +- [ ] **T03: Build text properties panel and convert-to-paths action in ShapeProperties** — Add text-specific property controls to ShapeProperties and implement the 'Convert to Paths' action that replaces a TextObject with path-based objects using fontService.textToPathData(). + +## Text Property Controls (in ShapeProperties) +When the selected object has type 'text', show: +1. **Text content** — textarea input bound to obj.text +2. **Font family** — dropdown/select populated from fontService.getAvailableFonts(), bound to obj.fontFamily +3. **Font size** — number input (min 8, max 200, step 1), bound to obj.fontSize +4. **Letter spacing** — number input (min -5, max 20, step 0.5), bound to obj.letterSpacing +5. **Line height** — number input (min 0.5, max 3, step 0.1), bound to obj.lineHeight +6. **Fill color** — color input (reuse existing pattern from hasFill), bound to obj.fill +7. **Stroke color + weight** — reuse existing stroke controls +8. **Convert to Paths button** — calls fontService.textToPathData(), then replaces the text object with a new path object (type 'image' with SVG data URL, or a new 'path' type if simpler) + +## Convert to Paths Implementation +- Show confirmation dialog (window.confirm) since this is destructive +- Call `textToPathData(obj.text, obj.fontFamily, obj.fontSize, obj.letterSpacing)` from fontService +- Create SVG string from returned path data: `` +- Convert SVG to Blob URL, create ImageObject replacing the TextObject at same x,y position +- Use onUpdate to remove old text object and add new image object (or dispatch through a callback prop) + +## Integration Notes +- fontService.ts from T01 provides loadFont(), getAvailableFonts(), textToPathData() +- The text property controls should only show when object.type === 'text' +- Existing stroke/fill/opacity/rotation controls still show for text objects +- ShapeProperties receives onUpdate callback from DesignCanvas — convert-to-paths needs onUpdate + onAddObject + onRemoveObject, so ShapeProperties props may need extending, or the convert action can be handled via a new callback prop +- DesignCanvas.tsx needs to pass the additional callback(s) to ShapeProperties + - Estimate: 1h + - Files: app/src/components/canvas/ShapeProperties.tsx, app/src/views/DesignCanvas.tsx + - Verify: cd app && npx tsc --noEmit && npx vitest run --reporter=verbose diff --git a/.gsd/milestones/M002/slices/S03/S03-RESEARCH.md b/.gsd/milestones/M002/slices/S03/S03-RESEARCH.md new file mode 100644 index 0000000..c3fd6bb --- /dev/null +++ b/.gsd/milestones/M002/slices/S03/S03-RESEARCH.md @@ -0,0 +1,76 @@ +# S03 — Text System + Font Loading — Research + +**Date:** 2026-03-26 + +## Summary + +This slice adds text objects to the Kerf design canvas with font loading, property editing, and text-to-path conversion using opentype.js. The work extends the existing discriminated union type system (D007/D008) by adding a `'text'` type, follows the established pattern of state management through `useCanvasState`, and renders text on the Konva canvas via `react-konva`'s `Text` component (for live editing) with conversion to `Path` components (for final output). + +The core risk is opentype.js integration — specifically loading `.ttf`/`.otf` files from `app/public/fonts/`, parsing them into `Font` objects, and extracting accurate SVG path data via `Font.getPath().toPathData()`. The opentype.js API (v1.3.4) is well-documented and provides exactly the methods needed: `opentype.parse(arrayBuffer)` to load fonts, `Font.getPath(text, x, y, fontSize)` to generate path objects, and `Path.toPathData({flipY: true})` to produce SVG path `d` attributes compatible with Konva's `Path` component. This is a targeted research — the pattern is clear, the APIs are known, the extension points are explicit. + +## Recommendation + +**Approach:** Bundle 3-5 open-source fonts (e.g., Roboto, Open Sans, Lato, Oswald, Playfair Display — all Google Fonts with OFL license) in `app/public/fonts/`. Create a `fontService` utility that lazily loads and caches parsed `opentype.Font` objects. Add `TextObject` to the canvas type system. Render text on canvas using Konva's `Text` component for live editing, then use opentype.js `Font.getPath().toPathData()` to convert text to Konva `Path` elements when the user triggers "Convert to Paths". Build a `TextProperties` panel section in `ShapeProperties` for font family, size, letter spacing, and line height controls. + +**Why:** This approach separates concerns cleanly — Konva `Text` handles display and editing (with system font rendering), while opentype.js handles the precision text-to-path conversion needed for downstream kerf-aware export. Bundled fonts guarantee availability without network dependencies. The font service pattern keeps parsing async and cached, avoiding repeated ArrayBuffer fetches. + +## Implementation Landscape + +### Key Files + +- `app/src/types/canvas.ts` — Add `TextObject` interface and `'text'` to `CanvasObject` union. Fields: `text`, `fontFamily`, `fontSize`, `letterSpacing`, `lineHeight`, `fill`, `stroke`, `strokeWidth`, `width` (for text wrapping), `pathData` (optional — set after conversion to paths). +- `app/src/utils/fontService.ts` — **New.** Font loading and caching service. Exports `loadFont(familyName)`, `getAvailableFonts()`, `textToPathData(text, fontFamily, fontSize, letterSpacing)`. Uses `opentype.parse()` internally. Caches parsed Font objects in a Map. +- `app/src/hooks/useCanvasState.ts` — No changes needed to the hook itself — the existing `ADD_OBJECT`, `UPDATE_OBJECT`, `REMOVE_OBJECT` actions work with any `CanvasObject` union member. The `'text'` type flows through automatically. +- `app/src/components/canvas/KonvaStage.tsx` — Add `case 'text':` to `renderObject()` switch. Import `Text` from `react-konva`. Also add `case 'text':` to `getObjWidth()`/`getObjHeight()` helpers. Add `'text'` to `CanvasTool` union and tool creation in `handleStageMouseDown`. +- `app/src/components/canvas/CanvasToolbar.tsx` — Add text tool button (`T`) to TOOLS array. +- `app/src/components/canvas/ShapeProperties.tsx` — Add `case 'text':` handling with text-specific property controls (font picker dropdown, font size, letter spacing, line height). Add "Convert to Paths" button that calls `textToPathData()`. +- `app/src/components/canvas/ObjectPanel.tsx` — Add `text: 'T'` to `TYPE_ICONS` map. +- `app/public/fonts/` — **New directory.** Place bundled `.ttf` font files here. They'll be served by Vite's static file server at `/fonts/`. +- `app/src/utils/__tests__/fontService.test.ts` — **New.** Tests for font loading, caching, and text-to-path conversion. + +### Build Order + +1. **Font service utility** (`fontService.ts` + tests) — Prove that opentype.js loads fonts and extracts path data correctly. This is the riskiest piece — if path extraction doesn't produce accurate SVG `d` attributes compatible with Konva's `Path`, everything downstream breaks. Test with a real bundled font file. +2. **Type system extension** (`canvas.ts`) — Add `TextObject` to discriminated union. Mechanical — just type definitions. Must happen before any component work. +3. **Canvas rendering + tool** (KonvaStage, CanvasToolbar, ObjectPanel) — Add text tool, `case 'text':` rendering via Konva `Text`, and tool icon. This wires text objects into the canvas without any property editing. +4. **Properties panel + convert-to-paths** (ShapeProperties) — Font picker, size/spacing controls, "Convert to Paths" button. The conversion replaces the `TextObject` with one or more `PathObject` entries (new type — a `Path` rendered via Konva `Path` from the `d` attribute). Or, more simply, it replaces the text object with an `ImageObject` using an SVG data URL built from the path data. + +**Why this order:** Font service is the only unknown — validating opentype.js path extraction first de-risks everything. Types must exist before components compile. Rendering before properties follows the pattern established in S02. + +### Verification Approach + +1. **Unit tests:** `fontService.test.ts` — load a bundled font, call `textToPathData("Hello", "Roboto", 48, 0)`, assert the returned string starts with `M` and contains path commands. Test caching (second call doesn't re-fetch). Test error handling for missing font. +2. **Type safety:** `npx tsc --noEmit` — zero errors with the new `'text'` type added to all switch statements. +3. **Existing tests pass:** `npx vitest run` — all 71 existing tests still pass (no regressions). +4. **New component tests:** Test text object creation via tool, text property updates, convert-to-paths action. +5. **Full test suite:** `cd app && npx vitest run --reporter=verbose` — all tests pass. + +## Don't Hand-Roll + +| Problem | Existing Solution | Why Use It | +|---------|------------------|------------| +| Font file parsing + glyph extraction | `opentype.js` (v1.3.4) | Industry-standard OpenType parser for JavaScript. `Font.getPath()` → `Path.toPathData()` gives exact SVG path data for text. Has TypeScript types. Already identified in roadmap. | +| Text rendering on canvas | `react-konva` `Text` component | Already in the project. Handles text display, wrapping, and basic styling. Used for live editing before conversion. | +| Path rendering on canvas | `react-konva` `Path` component | Already imported in KonvaStage.tsx. Renders SVG path `d` attributes. Used for text-to-path conversion output. | + +## Constraints + +- **opentype.js loads fonts from ArrayBuffer** — must `fetch()` font files as ArrayBuffer, then call `opentype.parse()`. Cannot use CSS `@font-face` for path extraction — that only gives rendering, not glyph geometry. +- **Konva `Text` uses system/CSS fonts, not opentype.js** — for live canvas display, Konva renders text using the browser's text engine with `fontFamily` CSS. The opentype.js font and the Konva display font must match visually. This means the bundled fonts must also be loaded via `@font-face` CSS for Konva rendering. +- **Path.toPathData() needs `flipY: true`** — font coordinate systems have Y increasing upward, SVG/canvas have Y increasing downward. The `flipY` option handles this automatically. +- **TypeScript strict mode with `noUnusedLocals` and `noUnusedParameters`** — all new code must pass strict checks. The `erasableSyntaxOnly: true` flag means type-only imports must use `import type`. +- **`CanvasTool` type is defined in KonvaStage.tsx** — adding `'text'` requires updating the union type there, which affects CanvasToolbar's import. + +## Common Pitfalls + +- **Font not loaded for Konva rendering** — If the `@font-face` CSS isn't loaded before Konva renders the `Text` node, it falls back to a default font. Use `document.fonts.ready` or `FontFace.load()` API to ensure CSS font is loaded before rendering. +- **opentype.js `getPath()` y parameter is baseline, not top** — The `y` argument is the text baseline position, not the top of the text bounding box. To position text at a specific y coordinate, compute `y + font.ascender * (fontSize / font.unitsPerEm)`. +- **Letter spacing not built into `getPath()`** — opentype.js handles kerning automatically but has no letter-spacing parameter. Must implement letter spacing by calling `Font.charToGlyph()` per character and manually advancing the x position by `glyph.advanceWidth * (fontSize / unitsPerEm) + letterSpacing`. +- **Convert-to-paths replaces the text object** — After conversion, the text is no longer editable as text. This should be a destructive action with user confirmation. +- **Font file size** — Each `.ttf` file is 100-500KB. Bundling 3-5 fonts adds 0.5-2.5MB to public assets. This is acceptable for a design tool but shouldn't be imported into the JS bundle — serve from `public/`. + +## Open Risks + +- **Letter spacing implementation complexity** — If per-character path generation for letter spacing proves complex, it could be deferred to a follow-up, offering only kerning (built-in to opentype.js) initially. +- **Multi-line text path conversion** — `Font.getPath()` produces a single path for one line of text. Multi-line text requires splitting by line breaks and offsetting each line's y position by `lineHeight * fontSize`. This is straightforward but adds implementation surface. +- **Font licensing for bundled fonts** — Must use OFL (Open Font License) or Apache-licensed fonts only. Google Fonts catalog fonts (Roboto, Open Sans, etc.) are safe. Verify license files are included. diff --git a/.gsd/milestones/M002/slices/S03/tasks/T01-PLAN.md b/.gsd/milestones/M002/slices/S03/tasks/T01-PLAN.md new file mode 100644 index 0000000..61d5e1c --- /dev/null +++ b/.gsd/milestones/M002/slices/S03/tasks/T01-PLAN.md @@ -0,0 +1,39 @@ +--- +estimated_steps: 10 +estimated_files: 5 +skills_used: [] +--- + +# T01: Build font service with opentype.js, bundle fonts, and add unit tests + +Create the fontService utility that loads .ttf fonts via fetch + opentype.parse(), caches parsed Font objects, and converts text to SVG path data. Bundle 3-5 OFL-licensed Google Fonts as .ttf files in app/public/fonts/. Add @font-face CSS declarations in App.css so Konva Text rendering matches the opentype.js fonts. Install opentype.js dependency. Write comprehensive unit tests for font loading, caching, and text-to-path conversion. + +This is the riskiest piece of the slice — if opentype.js path extraction doesn't produce accurate SVG `d` attributes compatible with Konva's Path component, everything downstream breaks. Testing with real bundled font files proves the integration. + +## Key Constraints +- opentype.js loads fonts from ArrayBuffer — must fetch() font files then call opentype.parse() +- Path.toPathData() needs consideration of coordinate systems (font Y-up vs canvas Y-down) +- Letter spacing is NOT built into getPath() — must implement per-character glyph positioning with manual x-advance +- Font files served from /fonts/ via Vite's public directory +- @font-face CSS must load before Konva renders Text nodes +- TypeScript strict mode with verbatimModuleSyntax — use `import type` for type-only imports +- Tests should mock fetch() to return real font file ArrayBuffers loaded from disk + +## Inputs + +- ``app/package.json` — add opentype.js dependency` +- ``app/src/App.css` — existing global styles, add @font-face declarations` + +## Expected Output + +- ``app/src/utils/fontService.ts` — font loading, caching, text-to-path conversion service` +- ``app/src/utils/__tests__/fontService.test.ts` — unit tests for fontService` +- ``app/public/fonts/Roboto-Regular.ttf` — bundled font file` +- ``app/public/fonts/OpenSans-Regular.ttf` — bundled font file` +- ``app/public/fonts/Lato-Regular.ttf` — bundled font file` +- ``app/package.json` — updated with opentype.js dependency` +- ``app/src/App.css` — updated with @font-face declarations` + +## Verification + +cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l diff --git a/.gsd/milestones/M002/slices/S03/tasks/T01-SUMMARY.md b/.gsd/milestones/M002/slices/S03/tasks/T01-SUMMARY.md new file mode 100644 index 0000000..e6f3f05 --- /dev/null +++ b/.gsd/milestones/M002/slices/S03/tasks/T01-SUMMARY.md @@ -0,0 +1,91 @@ +--- +id: T01 +parent: S03 +milestone: M002 +provides: [] +requires: [] +affects: [] +key_files: ["app/src/utils/fontService.ts", "app/src/utils/__tests__/fontService.test.ts", "app/public/fonts/Roboto-Regular.ttf", "app/public/fonts/OpenSans-Regular.ttf", "app/public/fonts/Lato-Regular.ttf", "app/src/App.css", "app/package.json"] +key_decisions: ["Used opentype.js v1.3.4 with dynamic import() and local type declarations (no @types package exists)", "Variable font .ttf files for Roboto/OpenSans validated to work with opentype.js glyph extraction", "Y-axis flip (canvas_y = ascender - font_y * scale) for canvas-compatible SVG path coordinates", "Manual per-character glyph positioning for letter spacing since opentype.js getPath() lacks native support"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Ran: cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l. All 24 tests pass, TypeScript compiles cleanly under strict mode, 3 font files confirmed in public/fonts/." +completed_at: 2026-03-26T05:52:47.243Z +blocker_discovered: false +--- + +# T01: Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests + +> Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests + +## What Happened +--- +id: T01 +parent: S03 +milestone: M002 +key_files: + - app/src/utils/fontService.ts + - app/src/utils/__tests__/fontService.test.ts + - app/public/fonts/Roboto-Regular.ttf + - app/public/fonts/OpenSans-Regular.ttf + - app/public/fonts/Lato-Regular.ttf + - app/src/App.css + - app/package.json +key_decisions: + - Used opentype.js v1.3.4 with dynamic import() and local type declarations (no @types package exists) + - Variable font .ttf files for Roboto/OpenSans validated to work with opentype.js glyph extraction + - Y-axis flip (canvas_y = ascender - font_y * scale) for canvas-compatible SVG path coordinates + - Manual per-character glyph positioning for letter spacing since opentype.js getPath() lacks native support +duration: "" +verification_result: passed +completed_at: 2026-03-26T05:52:47.258Z +blocker_discovered: false +--- + +# T01: Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests + +**Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests** + +## What Happened + +Installed opentype.js v1.3.4 as a production dependency. Downloaded three OFL-licensed Google Fonts (Roboto, Open Sans, Lato) as .ttf files into app/public/fonts/. Created app/src/utils/fontService.ts with loadFont, loadFontByFamily, textToPathData, getAvailableFonts, clearFontCache, and isFontCached exports. The service fetches .ttf files, parses them with opentype.js, caches parsed Font objects with in-flight deduplication, and converts text to SVG path data with manual per-character glyph positioning for letter spacing and Y-axis coordinate flip for canvas compatibility. Added @font-face declarations in App.css for all three fonts. Wrote 24 comprehensive unit tests covering font registry, loading/caching/deduplication/errors, family resolution, and text-to-path conversion with dimension scaling, letter spacing arithmetic, coordinate system validation, and edge cases. + +## Verification + +Ran: cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l. All 24 tests pass, TypeScript compiles cleanly under strict mode, 3 font files confirmed in public/fonts/. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose` | 0 | ✅ pass | 1050ms | +| 2 | `npx tsc --noEmit` | 0 | ✅ pass | 2200ms | +| 3 | `ls public/fonts/*.ttf | wc -l` | 0 | ✅ pass | 100ms | + + +## Deviations + +None. Variable font files used for Roboto and Open Sans instead of static weights — validated they work correctly with opentype.js. + +## Known Issues + +None. + +## Files Created/Modified + +- `app/src/utils/fontService.ts` +- `app/src/utils/__tests__/fontService.test.ts` +- `app/public/fonts/Roboto-Regular.ttf` +- `app/public/fonts/OpenSans-Regular.ttf` +- `app/public/fonts/Lato-Regular.ttf` +- `app/src/App.css` +- `app/package.json` + + +## Deviations +None. Variable font files used for Roboto and Open Sans instead of static weights — validated they work correctly with opentype.js. + +## Known Issues +None. diff --git a/.gsd/milestones/M002/slices/S03/tasks/T02-PLAN.md b/.gsd/milestones/M002/slices/S03/tasks/T02-PLAN.md new file mode 100644 index 0000000..0d40a4f --- /dev/null +++ b/.gsd/milestones/M002/slices/S03/tasks/T02-PLAN.md @@ -0,0 +1,42 @@ +--- +estimated_steps: 13 +estimated_files: 5 +skills_used: [] +--- + +# T02: Add TextObject type and wire text tool into canvas rendering + +Extend the CanvasObject discriminated union with a TextObject type. Wire text objects into all canvas components: KonvaStage (render via Konva Text + case in getObjWidth/getObjHeight + text tool creation in handleStageMouseDown), CanvasToolbar (add 'text' to CanvasTool union and TOOLS array), ObjectPanel (add 'text' icon to TYPE_ICONS), and DesignCanvas (pass through). This follows the exact pattern established in S02 for adding object types. + +## Key Implementation Details +- TextObject interface: extends BaseCanvasObject with type: 'text', text: string, fontFamily: string, fontSize: number, letterSpacing: number, lineHeight: number, fill: string, stroke: string, strokeWidth: number, width: number (for wrapping) +- CanvasTool union in KonvaStage.tsx must add 'text' — this is imported by CanvasToolbar +- KonvaStage renderObject switch: case 'text' renders from react-konva with fontFamily, fontSize, fill, stroke, strokeWidth, width, letterSpacing, lineHeight props +- KonvaStage handleStageMouseDown switch: case 'text' creates a TextObject with defaults (text: 'Text', fontFamily: 'Roboto', fontSize: 24, letterSpacing: 0, lineHeight: 1.2) +- KonvaStage getObjWidth for text: use obj.width (wrapping width) or estimate from fontSize * text.length * 0.6 +- KonvaStage getObjHeight for text: obj.fontSize * obj.lineHeight +- KonvaStage onTransformEnd for text: scale width, keep fontSize unchanged (text wraps to new width) +- ObjectPanel TYPE_ICONS: text → 'T' +- ShapeProperties getWidth/getHeight: add case 'text' returning obj.width and obj.fontSize * obj.lineHeight +- All switch statements must be exhaustive — TypeScript noFallthroughCasesInSwitch enforces this +- TypeScript strict mode — no unused locals/params, erasableSyntaxOnly, use import type + +## Inputs + +- ``app/src/types/canvas.ts` — existing CanvasObject discriminated union to extend` +- ``app/src/components/canvas/KonvaStage.tsx` — existing renderObject/getObjWidth/getObjHeight/handleStageMouseDown to extend` +- ``app/src/components/canvas/CanvasToolbar.tsx` — existing TOOLS array and CanvasTool import` +- ``app/src/components/canvas/ObjectPanel.tsx` — existing TYPE_ICONS map` +- ``app/src/components/canvas/ShapeProperties.tsx` — existing getWidth/getHeight helpers` + +## Expected Output + +- ``app/src/types/canvas.ts` — updated with TextObject interface and 'text' in CanvasObject union` +- ``app/src/components/canvas/KonvaStage.tsx` — updated with text rendering, text tool creation, 'text' in CanvasTool union, text cases in getObjWidth/getObjHeight` +- ``app/src/components/canvas/CanvasToolbar.tsx` — updated with text tool button in TOOLS array` +- ``app/src/components/canvas/ObjectPanel.tsx` — updated with text icon in TYPE_ICONS` +- ``app/src/components/canvas/ShapeProperties.tsx` — updated with text cases in getWidth/getHeight` + +## Verification + +cd app && npx tsc --noEmit && npx vitest run --reporter=verbose diff --git a/.gsd/milestones/M002/slices/S03/tasks/T03-PLAN.md b/.gsd/milestones/M002/slices/S03/tasks/T03-PLAN.md new file mode 100644 index 0000000..a8e7ff5 --- /dev/null +++ b/.gsd/milestones/M002/slices/S03/tasks/T03-PLAN.md @@ -0,0 +1,50 @@ +--- +estimated_steps: 23 +estimated_files: 2 +skills_used: [] +--- + +# T03: Build text properties panel and convert-to-paths action in ShapeProperties + +Add text-specific property controls to ShapeProperties and implement the 'Convert to Paths' action that replaces a TextObject with path-based objects using fontService.textToPathData(). + +## Text Property Controls (in ShapeProperties) +When the selected object has type 'text', show: +1. **Text content** — textarea input bound to obj.text +2. **Font family** — dropdown/select populated from fontService.getAvailableFonts(), bound to obj.fontFamily +3. **Font size** — number input (min 8, max 200, step 1), bound to obj.fontSize +4. **Letter spacing** — number input (min -5, max 20, step 0.5), bound to obj.letterSpacing +5. **Line height** — number input (min 0.5, max 3, step 0.1), bound to obj.lineHeight +6. **Fill color** — color input (reuse existing pattern from hasFill), bound to obj.fill +7. **Stroke color + weight** — reuse existing stroke controls +8. **Convert to Paths button** — calls fontService.textToPathData(), then replaces the text object with a new path object (type 'image' with SVG data URL, or a new 'path' type if simpler) + +## Convert to Paths Implementation +- Show confirmation dialog (window.confirm) since this is destructive +- Call `textToPathData(obj.text, obj.fontFamily, obj.fontSize, obj.letterSpacing)` from fontService +- Create SVG string from returned path data: `` +- Convert SVG to Blob URL, create ImageObject replacing the TextObject at same x,y position +- Use onUpdate to remove old text object and add new image object (or dispatch through a callback prop) + +## Integration Notes +- fontService.ts from T01 provides loadFont(), getAvailableFonts(), textToPathData() +- The text property controls should only show when object.type === 'text' +- Existing stroke/fill/opacity/rotation controls still show for text objects +- ShapeProperties receives onUpdate callback from DesignCanvas — convert-to-paths needs onUpdate + onAddObject + onRemoveObject, so ShapeProperties props may need extending, or the convert action can be handled via a new callback prop +- DesignCanvas.tsx needs to pass the additional callback(s) to ShapeProperties + +## Inputs + +- ``app/src/utils/fontService.ts` — font loading and text-to-path conversion from T01` +- ``app/src/types/canvas.ts` — TextObject type from T02` +- ``app/src/components/canvas/ShapeProperties.tsx` — existing property panel to extend with text controls from T02` +- ``app/src/views/DesignCanvas.tsx` — existing view container that passes props to ShapeProperties` + +## Expected Output + +- ``app/src/components/canvas/ShapeProperties.tsx` — updated with text property controls (font picker, size, spacing, content) and Convert to Paths button` +- ``app/src/views/DesignCanvas.tsx` — updated to pass additional callbacks to ShapeProperties for convert-to-paths action` + +## Verification + +cd app && npx tsc --noEmit && npx vitest run --reporter=verbose diff --git a/.gsd/state-manifest.json b/.gsd/state-manifest.json index 38c91ea..914a062 100644 --- a/.gsd/state-manifest.json +++ b/.gsd/state-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "exported_at": "2026-03-26T05:41:35.199Z", + "exported_at": "2026-03-26T05:52:47.299Z", "milestones": [ { "id": "M001", @@ -292,16 +292,16 @@ "milestone_id": "M002", "id": "S02", "title": "Design Canvas Core (View 2)", - "status": "pending", + "status": "complete", "risk": "medium — Konva.js setup, selection handles, undo/redo state management", "depends": [ "S01" ], "demo": "Create artboards in each shape, add/move/resize shapes, multi-select with alignment, undo/redo through history", "created_at": "2026-03-26T03:53:44.532Z", - "completed_at": null, - "full_summary_md": "", - "full_uat_md": "", + "completed_at": "2026-03-26T05:44:01.038Z", + "full_summary_md": "---\nid: S02\nparent: M002\nmilestone: M002\nprovides:\n - CanvasObject type system (types/canvas.ts) — discriminated union ready for 'text' type extension in S03\n - useCanvasState hook with full CRUD, selection, undo/redo, reorder, visibility/lock toggle\n - KonvaStage component rendering artboard + all object types + selection/transform\n - DesignCanvas view container wired into App.tsx with View 1 → View 2 data flow\n - Artboard shape utilities (shield, pennant paths, presets, unit conversion)\n - Alignment/distribute utility functions\n - Canvas panel system (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties)\nrequires:\n []\naffects:\n - S03\nkey_files:\n - app/src/types/canvas.ts\n - app/src/hooks/useCanvasState.ts\n - app/src/hooks/__tests__/useCanvasState.test.ts\n - app/src/utils/artboardShapes.ts\n - app/src/utils/__tests__/artboardShapes.test.ts\n - app/src/utils/alignment.ts\n - app/src/utils/__tests__/alignment.test.ts\n - app/src/components/canvas/ArtboardSetup.tsx\n - app/src/components/canvas/KonvaStage.tsx\n - app/src/components/canvas/ObjectPanel.tsx\n - app/src/components/canvas/AlignmentBar.tsx\n - app/src/components/canvas/CanvasToolbar.tsx\n - app/src/components/canvas/ShapeProperties.tsx\n - app/src/views/DesignCanvas.tsx\n - app/src/views/DesignCanvas.module.css\n - app/src/App.tsx\n - app/src/App.css\nkey_decisions:\n - D007: Canvas state uses useReducer + useRef pattern — reducer for renders, ref for undo/redo history. Select/deselect excluded from undo stack. History capped at 50.\n - D008: Canvas objects use TypeScript discriminated union on type field (rect|circle|ellipse|line|image) with shared BaseCanvasObject.\n - Artboard centered on stage via computed offsets; object positions stored relative to artboard origin\n - SVG import from View 1 uses Blob URL → Image element → Konva.Image with auto-scale to fit artboard\n - ObjectPanel displays objects in reverse z-order (frontmost at top) matching standard design tool UX\n - Keyboard shortcuts use window-level keydown with activeElement tag guard to skip input/textarea/select\npatterns_established:\n - Discriminated union CanvasObject type system — add new object types by extending the union and adding case handling\n - useCanvasState hook as single source of truth for all canvas mutations — all components dispatch through it\n - Panel components receive state + dispatch callbacks from DesignCanvas — no direct hook usage in panels\n - vitest-canvas-mock for Konva/canvas testing in Vitest (not jest-canvas-mock)\nobservability_surfaces:\n - none\ndrill_down_paths:\n - .gsd/milestones/M002/slices/S02/tasks/T01-SUMMARY.md\n - .gsd/milestones/M002/slices/S02/tasks/T02-SUMMARY.md\n - .gsd/milestones/M002/slices/S02/tasks/T03-SUMMARY.md\n - .gsd/milestones/M002/slices/S02/tasks/T04-SUMMARY.md\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T05:44:01.051Z\nblocker_discovered: false\n---\n\n# S02: Design Canvas Core (View 2)\n\n**Built the Konva.js-powered 2D design canvas with artboard setup, shape tools, selection/transform, layer management, alignment, properties editing, keyboard shortcuts, and undo/redo — 48 new tests (71 total), zero TypeScript errors.**\n\n## What Happened\n\nThis slice delivered the complete Design Canvas (View 2) for the Kerf Engine app across four tasks.\n\n**T01 — Foundation:** Established the canvas type system (`types/canvas.ts`) with a discriminated union for five object types (rect, circle, ellipse, line, image) sharing a `BaseCanvasObject`. Built the central `useCanvasState` hook using a useReducer + useRef pattern — reducer drives re-renders for current state, ref holds undo/redo history stacks without triggering renders. Select/deselect are excluded from the undo stack (UI-only concern). History is capped at 50 entries. Created artboard shape utilities (shield/pennant SVG paths, dimension presets, unit conversion), 9 pure alignment/distribute functions, and the `ArtboardSetup` modal component. Installed konva, react-konva, and vitest-canvas-mock. 48 new tests passing.\n\n**T02 — Rendering Layer:** Created `KonvaStage` rendering artboard backgrounds (Rect/Circle/Ellipse/Path based on shape config), mapping all canvas objects to Konva primitives with drag/transform handlers, Transformer synced to selected nodes, and rubber-band multi-select rectangle. Built `DesignCanvas` view container with ResizeObserver-driven sizing, artboard setup flow, and SVG import (Blob URL → Image element → Konva.Image with auto-scale). Wired into `App.tsx` to receive real svgData/traceMetadata props from View 1.\n\n**T03 — Panel System:** Built four panel components: `ObjectPanel` (layer list in reverse z-order with drag reorder, visibility/lock toggles, rename), `AlignmentBar` (6 alignment + 2 distribute + center-on-artboard), `CanvasToolbar` (tool switcher, undo/redo, grid toggle, zoom controls), and `ShapeProperties` (stroke/fill/dimensions/line-style editing with discriminated union narrowing). All wired to useCanvasState in DesignCanvas.\n\n**T04 — Shortcuts & Integration:** Added window-level keyboard shortcuts (Ctrl+Z undo, Ctrl+Shift+Z redo, Delete/Backspace remove, Escape deselect, Ctrl+A select all) with activeElement guard to skip input fields. Verified full integration: View 1 → Use This → Artboard Setup → Canvas with imported SVG. All 71 tests pass, zero TypeScript errors.\n\n## Verification\n\nRan `cd app && npx vitest run --reporter=verbose` — 71 tests pass across 6 test files, 0 failures (exit 0). Ran `cd app && npx tsc --noEmit` — zero type errors under strict mode with noUnusedLocals/noUnusedParameters (exit 0).\n\n## Requirements Advanced\n\nNone.\n\n## Requirements Validated\n\nNone.\n\n## New Requirements Surfaced\n\nNone.\n\n## Requirements Invalidated or Re-scoped\n\nNone.\n\n## Deviations\n\nUsed vitest-canvas-mock instead of jest-canvas-mock (plan specified jest-canvas-mock but it crashes in Vitest because it internally calls jest.fn()). This was identified in T01 and recorded as L010 in KNOWLEDGE.md.\n\n## Known Limitations\n\nCanvas components (KonvaStage, DesignCanvas panels) do not have dedicated unit tests — they were verified via TypeScript compilation and manual integration. Konva rendering in jsdom is limited since canvas operations are mocked. Drag-to-reorder in ObjectPanel uses simple index swapping, not full drag-and-drop library.\n\n## Follow-ups\n\nS03 will add 'text' type to the CanvasObject discriminated union and integrate opentype.js for font loading and text-to-path conversion. ShapeProperties panel will need a text-specific section. The canvas object type system (D008) was designed to accommodate this extension.\n\n## Files Created/Modified\n\n- `app/src/types/canvas.ts` — New — discriminated union type system for canvas objects (rect, circle, ellipse, line, image), ArtboardConfig, CanvasState, and CanvasAction types\n- `app/src/hooks/useCanvasState.ts` — New — central state management hook with useReducer+useRef for CRUD, selection, undo/redo, reorder, visibility/lock\n- `app/src/hooks/__tests__/useCanvasState.test.ts` — New — 20 tests covering all useCanvasState operations including undo/redo edge cases\n- `app/src/utils/artboardShapes.ts` — New — artboard shape presets, shield/pennant SVG path generators, toPx/fromPx unit conversion, artboardClipPath\n- `app/src/utils/__tests__/artboardShapes.test.ts` — New — 15 tests for artboard presets, path generation, unit conversion, clip paths\n- `app/src/utils/alignment.ts` — New — 9 pure alignment/distribute/center functions for canvas objects\n- `app/src/utils/__tests__/alignment.test.ts` — New — 13 tests for alignment, distribution, and center-on-artboard\n- `app/src/components/canvas/ArtboardSetup.tsx` — New — modal for artboard shape/size/unit selection on entering canvas view\n- `app/src/components/canvas/KonvaStage.tsx` — New — Konva Stage+Layer rendering artboard, all object types, Transformer, rubber-band selection\n- `app/src/components/canvas/ObjectPanel.tsx` — New — layer management panel with z-order list, reorder, visibility/lock toggles, rename\n- `app/src/components/canvas/AlignmentBar.tsx` — New — alignment and distribution toolbar consuming alignment utils\n- `app/src/components/canvas/CanvasToolbar.tsx` — New — tool switcher, undo/redo, grid toggle, zoom controls\n- `app/src/components/canvas/ShapeProperties.tsx` — New — property editor for selected shape (stroke, fill, dimensions, line style, opacity)\n- `app/src/views/DesignCanvas.tsx` — New — View 2 container wiring KonvaStage + all panels to useCanvasState, SVG import, keyboard shortcuts\n- `app/src/views/DesignCanvas.module.css` — New — CSS module for DesignCanvas layout\n- `app/src/App.tsx` — Modified — wired DesignCanvas with svgData/traceMetadata props, removed underscore prefixes\n- `app/src/App.css` — Modified — added comprehensive canvas UI styles (toolbar, panels, artboard setup, shape properties, alignment bar)\n- `app/src/test-setup.ts` — Modified — added vitest-canvas-mock import for Konva testing\n- `app/package.json` — Modified — added konva, react-konva, vitest-canvas-mock dependencies\n", + "full_uat_md": "# S02: Design Canvas Core (View 2) — UAT\n\n**Milestone:** M002\n**Written:** 2026-03-26T05:44:01.052Z\n\n## UAT: Design Canvas Core (View 2)\n\n### Preconditions\n- Engine API running at localhost:8000\n- App dev server running (`cd app && npm run dev`)\n- A raster image (PNG/JPG) available for upload\n\n---\n\n### Test 1: View 1 → View 2 Transition\n1. Upload a raster image in View 1\n2. Wait for trace preview to render\n3. Click \"Use This\" button\n4. **Expected:** Artboard Setup modal appears with shape picker, dimension inputs, and unit toggle\n\n### Test 2: Artboard Setup — Shape Selection\n1. In Artboard Setup modal, click each shape option: rect, square, circle, oval, shield, pennant\n2. **Expected:** Shape preview updates for each selection. Square and circle auto-set equal width/height. Width/height inputs update with preset dimensions.\n3. Toggle units between inches and mm\n4. **Expected:** Dimension values convert correctly (e.g., 3in → 76.2mm)\n5. Click \"Create Artboard\"\n6. **Expected:** Modal closes, Konva canvas renders with artboard background matching chosen shape\n\n### Test 3: Shape Creation Tools\n1. Click Rectangle tool in toolbar\n2. Click on the canvas\n3. **Expected:** A rectangle appears at the click position with default dimensions, black stroke, no fill\n4. Repeat with Circle tool and Ellipse tool\n5. **Expected:** Each shape appears correctly with default styling\n6. Click Line tool and click on canvas\n7. **Expected:** A horizontal line appears at the click position\n\n### Test 4: Object Selection & Transform\n1. Click on a shape on the canvas\n2. **Expected:** Selection handles (Transformer) appear around the shape. ObjectPanel highlights the selected row.\n3. Drag the shape to a new position\n4. **Expected:** Shape moves with cursor. Position updates in state.\n5. Drag a transform handle to resize\n6. **Expected:** Shape resizes. Width/height update (scaleX/scaleY reset to 1, dimensions multiplied).\n7. Click empty canvas area\n8. **Expected:** Shape deselected. Transformer hidden. ObjectPanel row unhighlighted.\n\n### Test 5: Multi-Select with Rubber Band\n1. Create 3+ shapes on canvas\n2. Click and drag on empty canvas area to draw a rubber-band rectangle over multiple shapes\n3. **Expected:** All shapes intersecting the rectangle are selected. Transformer wraps all selected nodes. Multiple rows highlighted in ObjectPanel.\n4. Shift-click an unselected shape\n5. **Expected:** Shape added to selection without deselecting others.\n\n### Test 6: ObjectPanel — Layer Management\n1. Create 3 shapes (they appear as layers in ObjectPanel in reverse z-order — newest on top)\n2. **Expected:** ObjectPanel lists all 3 shapes with type icons and default names\n3. Double-click a shape name in ObjectPanel\n4. **Expected:** Name becomes editable. Type new name, press Enter — name persists.\n5. Click the eye icon on a shape row\n6. **Expected:** Shape becomes invisible on canvas. Eye icon toggles to \"hidden\" state.\n7. Click the lock icon on a shape row\n8. **Expected:** Shape can no longer be dragged or resized on canvas.\n\n### Test 7: Alignment Tools\n1. Select 2+ shapes\n2. Click \"Align Left\" in AlignmentBar\n3. **Expected:** All selected shapes move so their left edges align with the leftmost shape's left edge\n4. Click \"Distribute Horizontally\" (requires 3+ shapes selected)\n5. **Expected:** Shapes evenly space horizontally\n6. Select 1 shape, click \"Center on Artboard\"\n7. **Expected:** Shape centers within the artboard bounds\n\n### Test 8: Shape Properties Panel\n1. Select a single rectangle\n2. **Expected:** ShapeProperties panel appears showing stroke color, stroke weight, fill color, width, height, opacity\n3. Change stroke color using color input\n4. **Expected:** Shape stroke updates immediately on canvas\n5. Change fill color and enable fill toggle\n6. **Expected:** Shape fill updates on canvas\n7. Select a line object\n8. **Expected:** ShapeProperties shows line style dropdown (solid, dashed, dotted). Changing style updates the line's dash array.\n\n### Test 9: Undo/Redo\n1. Create a shape, move it, change its color (3 mutations)\n2. Press Ctrl+Z three times\n3. **Expected:** Each undo reverses one mutation in order: color reverts → position reverts → shape removed\n4. Press Ctrl+Shift+Z (or Ctrl+Y) twice\n5. **Expected:** Shape reappears, position restored\n6. Click undo/redo buttons in CanvasToolbar\n7. **Expected:** Same behavior as keyboard shortcuts. Buttons disabled when respective stack is empty.\n\n### Test 10: Keyboard Shortcuts\n1. Select a shape, press Delete (or Backspace)\n2. **Expected:** Shape removed from canvas and ObjectPanel\n3. Create multiple shapes, press Ctrl+A\n4. **Expected:** All shapes selected\n5. Press Escape\n6. **Expected:** All shapes deselected\n7. Focus a text input (e.g., shape name rename in ObjectPanel), press Delete\n8. **Expected:** Shortcut does NOT fire — normal text editing behavior occurs\n\n### Test 11: SVG Import from View 1\n1. Upload an image in View 1, complete tracing, click \"Use This\"\n2. Set up artboard, confirm\n3. **Expected:** Traced SVG appears as an image object on the canvas, auto-scaled to fit within artboard bounds. Object appears in ObjectPanel as an image type.\n\n### Test 12: Canvas Zoom & Grid\n1. Click zoom-in button in CanvasToolbar\n2. **Expected:** Canvas content appears larger\n3. Click zoom-out button\n4. **Expected:** Canvas content appears smaller\n5. Click \"Fit\" button\n6. **Expected:** Artboard fits within visible canvas area\n7. Toggle grid button\n8. **Expected:** Grid overlay toggles on/off on canvas\n\n### Edge Cases\n- **Empty undo:** Pressing Ctrl+Z with no history should do nothing (no crash)\n- **Delete with nothing selected:** Pressing Delete with no selection should do nothing\n- **Rapid undo/redo:** Rapidly alternating Ctrl+Z and Ctrl+Shift+Z should not corrupt state\n- **Window resize:** Canvas should resize responsively via ResizeObserver without layout breaks\n", "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.", "success_criteria": "## Must-Haves\n\n- Artboard setup modal with shape picker (rect, square, circle, oval, shield, pennant, custom) and dimension inputs (width/height, inches/mm)\n- Konva.js canvas rendering artboard background, imported SVG from View 1 as a movable/scalable object\n- Shape creation tools: rectangle, circle, ellipse, line — each draggable, resizable, rotatable via Transformer\n- Multi-select via shift-click and rubber-band rectangle selection\n- Object/layer panel: list objects by z-order, drag-to-reorder, visibility toggle, lock toggle, click-to-select, rename\n- Alignment bar: align left/center/right/top/middle/bottom, distribute H/V, center on artboard\n- Undo/redo via Ctrl+Z / Ctrl+Shift+Z and toolbar buttons, covering all mutations\n- Delete key removes selected objects\n- Shape properties panel: stroke color, stroke weight, fill toggle, dimensions display, line style for lines\n- All existing 23 tests pass, plus new tests for state hook, alignment math, artboard shapes, and components\n- Zero TypeScript errors\n\n## Proof Level\n\n- This slice proves: integration\n- Real runtime required: yes (visual verification of Konva rendering via dev server)\n- Human/UAT required: no\n\n## Verification\n\n- `cd app && npx vitest run --reporter=verbose` — all tests pass (existing 23 + new)\n- `cd app && npx tsc --noEmit` — zero TypeScript errors\n- New test files: `app/src/hooks/__tests__/useCanvasState.test.ts`, `app/src/utils/__tests__/alignment.test.ts`, `app/src/utils/__tests__/artboardShapes.test.ts`\n- All planned component files exist and render without error\n\n## Observability / Diagnostics\n\n- Runtime signals: Canvas state transitions (object count, selection changes) visible via React DevTools; undo/redo stack depth inspectable\n- Inspection surfaces: useCanvasState hook state accessible through React component tree in browser DevTools\n- Failure visibility: TypeScript compile errors surface at build time; Konva rendering failures appear as blank canvas with console errors\n- Redaction constraints: none\n\n## Integration Closure\n\n- Upstream surfaces consumed: `app/src/App.tsx` ViewState routing and svgResult/traceMetadata state from S01; `app/src/types/engine.ts` TraceMetadata interface; CSS variables from `app/src/index.css`\n- New wiring introduced: DesignCanvas view component replaces placeholder div in App.tsx; konva and react-konva npm dependencies added\n- What remains before the milestone is truly usable end-to-end: S03 (Text System + Font Loading) adds text objects and font picker", "proof_level": "integration", @@ -324,11 +324,11 @@ "completed_at": null, "full_summary_md": "", "full_uat_md": "", - "goal": "Add text objects with opentype.js font rendering, font picker from fonts.json manifest, text-to-paths conversion", - "success_criteria": "- Text objects addable via toolbar\n- Font picker loads from fonts.json manifest\n- Font size, letter spacing, line height adjustable\n- Text alignment: left, center, right\n- 'Convert to paths' produces accurate vector paths\n- Converted paths render identically to text preview", - "proof_level": "integration — fonts load, render, and convert to paths correctly", - "integration_closure": "Canvas feature-complete; all object types functional; ready for Human Checkpoint 2", - "observability_impact": "Font loading status; path conversion node count", + "goal": "Add text objects to the design canvas with font loading from bundled fonts, property editing (font family, size, letter spacing, line height), and text-to-path conversion using opentype.js.", + "success_criteria": "## Must-Haves\n\n- `TextObject` type in discriminated union with text, fontFamily, fontSize, letterSpacing, lineHeight, fill, stroke, strokeWidth fields\n- Font service that loads bundled .ttf fonts via opentype.js `parse()`, caches parsed Font objects, and converts text to SVG path data via `Font.getPath().toPathData()`\n- 3-5 bundled OFL-licensed .ttf font files in `app/public/fonts/` with `@font-face` CSS declarations for Konva rendering\n- Text tool in CanvasToolbar that creates text objects on canvas click\n- Text objects rendered on canvas via Konva `Text` component\n- Text-specific property controls in ShapeProperties: font family picker, font size, letter spacing, line height, text content editing\n- \"Convert to Paths\" button that replaces a text object with path objects using opentype.js path extraction\n- All existing 71 tests pass plus new fontService tests\n- `npx tsc --noEmit` passes with zero errors\n\n## Proof Level\n\n- This slice proves: integration\n- Real runtime required: yes (font loading via fetch, @font-face CSS)\n- Human/UAT required: no\n\n## Verification\n\n- `cd app && npx vitest run --reporter=verbose` — all tests pass (existing 71 + new fontService tests)\n- `cd app && npx tsc --noEmit` — zero TypeScript errors\n- `test -f app/src/utils/fontService.ts` — font service exists\n- `test -f app/src/utils/__tests__/fontService.test.ts` — font service tests exist\n- `test -d app/public/fonts` — bundled fonts directory exists\n- `ls app/public/fonts/*.ttf | wc -l` — at least 3 font files bundled\n- `grep -q \"'text'\" app/src/types/canvas.ts` — text type in union\n- `grep -q \"text\" app/src/components/canvas/CanvasToolbar.tsx` — text tool in toolbar\n- `grep -q \"Convert to Paths\" app/src/components/canvas/ShapeProperties.tsx` — convert button exists\n\n## Integration Closure\n\n- Upstream surfaces consumed: `app/src/types/canvas.ts` (CanvasObject discriminated union from S02), `app/src/hooks/useCanvasState.ts` (existing CRUD actions), `app/src/components/canvas/KonvaStage.tsx` (renderObject switch), `app/src/components/canvas/ShapeProperties.tsx` (property panel), `app/src/components/canvas/CanvasToolbar.tsx` (tool buttons), `app/src/components/canvas/ObjectPanel.tsx` (type icons)\n- New wiring introduced in this slice: `app/src/utils/fontService.ts` (new font loading/caching module), `app/public/fonts/` (bundled font assets), `@font-face` CSS in `app/src/App.css`, text tool → KonvaStage mouse handler, ShapeProperties → fontService for convert-to-paths\n- What remains before the milestone is truly usable end-to-end: nothing — this is the final slice in M002", + "proof_level": "integration — validates opentype.js font parsing + path extraction with real font files, TypeScript type safety across all switch statements, and component integration", + "integration_closure": "Upstream: S02's CanvasObject discriminated union, useCanvasState hook, KonvaStage/ShapeProperties/CanvasToolbar/ObjectPanel components. New: fontService.ts, bundled fonts, @font-face CSS, text tool wiring. Remaining: nothing — S03 is the final slice in M002.", + "observability_impact": "Font loading errors logged to console via fontService. Failed font fetches surface as console.error with font family name. Convert-to-paths failures shown to user via window.alert (destructive action).", "sequence": 0, "replan_triggered_at": null }, @@ -1267,6 +1267,146 @@ "observability_impact": "", "full_plan_md": "", "sequence": 0 + }, + { + "milestone_id": "M002", + "slice_id": "S03", + "id": "T01", + "title": "Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests", + "status": "complete", + "one_liner": "Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests", + "narrative": "Installed opentype.js v1.3.4 as a production dependency. Downloaded three OFL-licensed Google Fonts (Roboto, Open Sans, Lato) as .ttf files into app/public/fonts/. Created app/src/utils/fontService.ts with loadFont, loadFontByFamily, textToPathData, getAvailableFonts, clearFontCache, and isFontCached exports. The service fetches .ttf files, parses them with opentype.js, caches parsed Font objects with in-flight deduplication, and converts text to SVG path data with manual per-character glyph positioning for letter spacing and Y-axis coordinate flip for canvas compatibility. Added @font-face declarations in App.css for all three fonts. Wrote 24 comprehensive unit tests covering font registry, loading/caching/deduplication/errors, family resolution, and text-to-path conversion with dimension scaling, letter spacing arithmetic, coordinate system validation, and edge cases.", + "verification_result": "Ran: cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l. All 24 tests pass, TypeScript compiles cleanly under strict mode, 3 font files confirmed in public/fonts/.", + "duration": "", + "completed_at": "2026-03-26T05:52:47.243Z", + "blocker_discovered": false, + "deviations": "None. Variable font files used for Roboto and Open Sans instead of static weights — validated they work correctly with opentype.js.", + "known_issues": "None.", + "key_files": [ + "app/src/utils/fontService.ts", + "app/src/utils/__tests__/fontService.test.ts", + "app/public/fonts/Roboto-Regular.ttf", + "app/public/fonts/OpenSans-Regular.ttf", + "app/public/fonts/Lato-Regular.ttf", + "app/src/App.css", + "app/package.json" + ], + "key_decisions": [ + "Used opentype.js v1.3.4 with dynamic import() and local type declarations (no @types package exists)", + "Variable font .ttf files for Roboto/OpenSans validated to work with opentype.js glyph extraction", + "Y-axis flip (canvas_y = ascender - font_y * scale) for canvas-compatible SVG path coordinates", + "Manual per-character glyph positioning for letter spacing since opentype.js getPath() lacks native support" + ], + "full_summary_md": "---\nid: T01\nparent: S03\nmilestone: M002\nkey_files:\n - app/src/utils/fontService.ts\n - app/src/utils/__tests__/fontService.test.ts\n - app/public/fonts/Roboto-Regular.ttf\n - app/public/fonts/OpenSans-Regular.ttf\n - app/public/fonts/Lato-Regular.ttf\n - app/src/App.css\n - app/package.json\nkey_decisions:\n - Used opentype.js v1.3.4 with dynamic import() and local type declarations (no @types package exists)\n - Variable font .ttf files for Roboto/OpenSans validated to work with opentype.js glyph extraction\n - Y-axis flip (canvas_y = ascender - font_y * scale) for canvas-compatible SVG path coordinates\n - Manual per-character glyph positioning for letter spacing since opentype.js getPath() lacks native support\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T05:52:47.258Z\nblocker_discovered: false\n---\n\n# T01: Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests\n\n**Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests**\n\n## What Happened\n\nInstalled opentype.js v1.3.4 as a production dependency. Downloaded three OFL-licensed Google Fonts (Roboto, Open Sans, Lato) as .ttf files into app/public/fonts/. Created app/src/utils/fontService.ts with loadFont, loadFontByFamily, textToPathData, getAvailableFonts, clearFontCache, and isFontCached exports. The service fetches .ttf files, parses them with opentype.js, caches parsed Font objects with in-flight deduplication, and converts text to SVG path data with manual per-character glyph positioning for letter spacing and Y-axis coordinate flip for canvas compatibility. Added @font-face declarations in App.css for all three fonts. Wrote 24 comprehensive unit tests covering font registry, loading/caching/deduplication/errors, family resolution, and text-to-path conversion with dimension scaling, letter spacing arithmetic, coordinate system validation, and edge cases.\n\n## Verification\n\nRan: cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l. All 24 tests pass, TypeScript compiles cleanly under strict mode, 3 font files confirmed in public/fonts/.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose` | 0 | ✅ pass | 1050ms |\n| 2 | `npx tsc --noEmit` | 0 | ✅ pass | 2200ms |\n| 3 | `ls public/fonts/*.ttf | wc -l` | 0 | ✅ pass | 100ms |\n\n\n## Deviations\n\nNone. Variable font files used for Roboto and Open Sans instead of static weights — validated they work correctly with opentype.js.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `app/src/utils/fontService.ts`\n- `app/src/utils/__tests__/fontService.test.ts`\n- `app/public/fonts/Roboto-Regular.ttf`\n- `app/public/fonts/OpenSans-Regular.ttf`\n- `app/public/fonts/Lato-Regular.ttf`\n- `app/src/App.css`\n- `app/package.json`\n", + "description": "Create the fontService utility that loads .ttf fonts via fetch + opentype.parse(), caches parsed Font objects, and converts text to SVG path data. Bundle 3-5 OFL-licensed Google Fonts as .ttf files in app/public/fonts/. Add @font-face CSS declarations in App.css so Konva Text rendering matches the opentype.js fonts. Install opentype.js dependency. Write comprehensive unit tests for font loading, caching, and text-to-path conversion.\n\nThis is the riskiest piece of the slice — if opentype.js path extraction doesn't produce accurate SVG `d` attributes compatible with Konva's Path component, everything downstream breaks. Testing with real bundled font files proves the integration.\n\n## Key Constraints\n- opentype.js loads fonts from ArrayBuffer — must fetch() font files then call opentype.parse()\n- Path.toPathData() needs consideration of coordinate systems (font Y-up vs canvas Y-down)\n- Letter spacing is NOT built into getPath() — must implement per-character glyph positioning with manual x-advance\n- Font files served from /fonts/ via Vite's public directory\n- @font-face CSS must load before Konva renders Text nodes\n- TypeScript strict mode with verbatimModuleSyntax — use `import type` for type-only imports\n- Tests should mock fetch() to return real font file ArrayBuffers loaded from disk", + "estimate": "1h", + "files": [ + "app/src/utils/fontService.ts", + "app/src/utils/__tests__/fontService.test.ts", + "app/public/fonts/", + "app/src/App.css", + "app/package.json" + ], + "verify": "cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l", + "inputs": [ + "`app/package.json` — add opentype.js dependency", + "`app/src/App.css` — existing global styles, add @font-face declarations" + ], + "expected_output": [ + "`app/src/utils/fontService.ts` — font loading, caching, text-to-path conversion service", + "`app/src/utils/__tests__/fontService.test.ts` — unit tests for fontService", + "`app/public/fonts/Roboto-Regular.ttf` — bundled font file", + "`app/public/fonts/OpenSans-Regular.ttf` — bundled font file", + "`app/public/fonts/Lato-Regular.ttf` — bundled font file", + "`app/package.json` — updated with opentype.js dependency", + "`app/src/App.css` — updated with @font-face declarations" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M002", + "slice_id": "S03", + "id": "T02", + "title": "Add TextObject type and wire text tool into canvas rendering", + "status": "pending", + "one_liner": "", + "narrative": "", + "verification_result": "", + "duration": "", + "completed_at": null, + "blocker_discovered": false, + "deviations": "", + "known_issues": "", + "key_files": [], + "key_decisions": [], + "full_summary_md": "", + "description": "Extend the CanvasObject discriminated union with a TextObject type. Wire text objects into all canvas components: KonvaStage (render via Konva Text + case in getObjWidth/getObjHeight + text tool creation in handleStageMouseDown), CanvasToolbar (add 'text' to CanvasTool union and TOOLS array), ObjectPanel (add 'text' icon to TYPE_ICONS), and DesignCanvas (pass through). This follows the exact pattern established in S02 for adding object types.\n\n## Key Implementation Details\n- TextObject interface: extends BaseCanvasObject with type: 'text', text: string, fontFamily: string, fontSize: number, letterSpacing: number, lineHeight: number, fill: string, stroke: string, strokeWidth: number, width: number (for wrapping)\n- CanvasTool union in KonvaStage.tsx must add 'text' — this is imported by CanvasToolbar\n- KonvaStage renderObject switch: case 'text' renders from react-konva with fontFamily, fontSize, fill, stroke, strokeWidth, width, letterSpacing, lineHeight props\n- KonvaStage handleStageMouseDown switch: case 'text' creates a TextObject with defaults (text: 'Text', fontFamily: 'Roboto', fontSize: 24, letterSpacing: 0, lineHeight: 1.2)\n- KonvaStage getObjWidth for text: use obj.width (wrapping width) or estimate from fontSize * text.length * 0.6\n- KonvaStage getObjHeight for text: obj.fontSize * obj.lineHeight\n- KonvaStage onTransformEnd for text: scale width, keep fontSize unchanged (text wraps to new width)\n- ObjectPanel TYPE_ICONS: text → 'T'\n- ShapeProperties getWidth/getHeight: add case 'text' returning obj.width and obj.fontSize * obj.lineHeight\n- All switch statements must be exhaustive — TypeScript noFallthroughCasesInSwitch enforces this\n- TypeScript strict mode — no unused locals/params, erasableSyntaxOnly, use import type", + "estimate": "1h", + "files": [ + "app/src/types/canvas.ts", + "app/src/components/canvas/KonvaStage.tsx", + "app/src/components/canvas/CanvasToolbar.tsx", + "app/src/components/canvas/ObjectPanel.tsx", + "app/src/components/canvas/ShapeProperties.tsx" + ], + "verify": "cd app && npx tsc --noEmit && npx vitest run --reporter=verbose", + "inputs": [ + "`app/src/types/canvas.ts` — existing CanvasObject discriminated union to extend", + "`app/src/components/canvas/KonvaStage.tsx` — existing renderObject/getObjWidth/getObjHeight/handleStageMouseDown to extend", + "`app/src/components/canvas/CanvasToolbar.tsx` — existing TOOLS array and CanvasTool import", + "`app/src/components/canvas/ObjectPanel.tsx` — existing TYPE_ICONS map", + "`app/src/components/canvas/ShapeProperties.tsx` — existing getWidth/getHeight helpers" + ], + "expected_output": [ + "`app/src/types/canvas.ts` — updated with TextObject interface and 'text' in CanvasObject union", + "`app/src/components/canvas/KonvaStage.tsx` — updated with text rendering, text tool creation, 'text' in CanvasTool union, text cases in getObjWidth/getObjHeight", + "`app/src/components/canvas/CanvasToolbar.tsx` — updated with text tool button in TOOLS array", + "`app/src/components/canvas/ObjectPanel.tsx` — updated with text icon in TYPE_ICONS", + "`app/src/components/canvas/ShapeProperties.tsx` — updated with text cases in getWidth/getHeight" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 + }, + { + "milestone_id": "M002", + "slice_id": "S03", + "id": "T03", + "title": "Build text properties panel and convert-to-paths action in ShapeProperties", + "status": "pending", + "one_liner": "", + "narrative": "", + "verification_result": "", + "duration": "", + "completed_at": null, + "blocker_discovered": false, + "deviations": "", + "known_issues": "", + "key_files": [], + "key_decisions": [], + "full_summary_md": "", + "description": "Add text-specific property controls to ShapeProperties and implement the 'Convert to Paths' action that replaces a TextObject with path-based objects using fontService.textToPathData().\n\n## Text Property Controls (in ShapeProperties)\nWhen the selected object has type 'text', show:\n1. **Text content** — textarea input bound to obj.text\n2. **Font family** — dropdown/select populated from fontService.getAvailableFonts(), bound to obj.fontFamily\n3. **Font size** — number input (min 8, max 200, step 1), bound to obj.fontSize\n4. **Letter spacing** — number input (min -5, max 20, step 0.5), bound to obj.letterSpacing\n5. **Line height** — number input (min 0.5, max 3, step 0.1), bound to obj.lineHeight\n6. **Fill color** — color input (reuse existing pattern from hasFill), bound to obj.fill\n7. **Stroke color + weight** — reuse existing stroke controls\n8. **Convert to Paths button** — calls fontService.textToPathData(), then replaces the text object with a new path object (type 'image' with SVG data URL, or a new 'path' type if simpler)\n\n## Convert to Paths Implementation\n- Show confirmation dialog (window.confirm) since this is destructive\n- Call `textToPathData(obj.text, obj.fontFamily, obj.fontSize, obj.letterSpacing)` from fontService\n- Create SVG string from returned path data: ``\n- Convert SVG to Blob URL, create ImageObject replacing the TextObject at same x,y position\n- Use onUpdate to remove old text object and add new image object (or dispatch through a callback prop)\n\n## Integration Notes\n- fontService.ts from T01 provides loadFont(), getAvailableFonts(), textToPathData()\n- The text property controls should only show when object.type === 'text'\n- Existing stroke/fill/opacity/rotation controls still show for text objects\n- ShapeProperties receives onUpdate callback from DesignCanvas — convert-to-paths needs onUpdate + onAddObject + onRemoveObject, so ShapeProperties props may need extending, or the convert action can be handled via a new callback prop\n- DesignCanvas.tsx needs to pass the additional callback(s) to ShapeProperties", + "estimate": "1h", + "files": [ + "app/src/components/canvas/ShapeProperties.tsx", + "app/src/views/DesignCanvas.tsx" + ], + "verify": "cd app && npx tsc --noEmit && npx vitest run --reporter=verbose", + "inputs": [ + "`app/src/utils/fontService.ts` — font loading and text-to-path conversion from T01", + "`app/src/types/canvas.ts` — TextObject type from T02", + "`app/src/components/canvas/ShapeProperties.tsx` — existing property panel to extend with text controls from T02", + "`app/src/views/DesignCanvas.tsx` — existing view container that passes props to ShapeProperties" + ], + "expected_output": [ + "`app/src/components/canvas/ShapeProperties.tsx` — updated with text property controls (font picker, size, spacing, content) and Convert to Paths button", + "`app/src/views/DesignCanvas.tsx` — updated to pass additional callbacks to ShapeProperties for convert-to-paths action" + ], + "observability_impact": "", + "full_plan_md": "", + "sequence": 0 } ], "decisions": [ @@ -1341,6 +1481,18 @@ "revisable": "Yes", "made_by": "agent", "superseded_by": null + }, + { + "seq": 16, + "id": "D007", + "when_context": "", + "scope": "architecture", + "decision": "Canvas object type system design", + "choice": "Discriminated union on `type` field: 'rect' | 'circle' | 'ellipse' | 'line' | 'image'. All objects share BaseCanvasObject (id, name, x, y, visible, locked, stroke, fill, opacity). Type-specific fields via intersection types.", + "rationale": "TypeScript discriminated unions enable exhaustive type narrowing in switch/if statements, catching missing cases at compile time. Shared base keeps CRUD operations generic while type-specific panels (ShapeProperties) safely narrow to access unique fields. 'text' type will be added in S03.", + "revisable": "Yes", + "made_by": "agent", + "superseded_by": null } ], "verification_evidence": [ @@ -1673,6 +1825,39 @@ "verdict": "✅ pass", "duration_ms": 2100, "created_at": "2026-03-26T05:41:35.156Z" + }, + { + "id": 31, + "task_id": "T01", + "slice_id": "S03", + "milestone_id": "M002", + "command": "npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 1050, + "created_at": "2026-03-26T05:52:47.243Z" + }, + { + "id": 32, + "task_id": "T01", + "slice_id": "S03", + "milestone_id": "M002", + "command": "npx tsc --noEmit", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 2200, + "created_at": "2026-03-26T05:52:47.243Z" + }, + { + "id": 33, + "task_id": "T01", + "slice_id": "S03", + "milestone_id": "M002", + "command": "ls public/fonts/*.ttf | wc -l", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 100, + "created_at": "2026-03-26T05:52:47.243Z" } ] } \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index 27a8dc4..524a410 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "konva": "^10.2.3", + "opentype.js": "^1.3.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.3" @@ -3202,6 +3203,22 @@ ], "license": "MIT" }, + "node_modules/opentype.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", + "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", + "license": "MIT", + "dependencies": { + "string.prototype.codepointat": "^0.2.1", + "tiny-inflate": "^1.0.3" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3637,6 +3654,12 @@ "dev": true, "license": "MIT" }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "license": "MIT" + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3683,6 +3706,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/app/package.json b/app/package.json index 714381a..4a0fb4e 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "konva": "^10.2.3", + "opentype.js": "^1.3.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.3" diff --git a/app/public/fonts/Lato-Regular.ttf b/app/public/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..0f3d0f8 Binary files /dev/null and b/app/public/fonts/Lato-Regular.ttf differ diff --git a/app/public/fonts/OpenSans-Regular.ttf b/app/public/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..9db8569 Binary files /dev/null and b/app/public/fonts/OpenSans-Regular.ttf differ diff --git a/app/public/fonts/Roboto-Regular.ttf b/app/public/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..5522a36 Binary files /dev/null and b/app/public/fonts/Roboto-Regular.ttf differ diff --git a/app/src/App.css b/app/src/App.css index 2ab5cb6..747f858 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -1,3 +1,28 @@ +/* ── Bundled font @font-face declarations ── */ +@font-face { + font-family: 'Roboto'; + src: url('/fonts/Roboto-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Open Sans'; + src: url('/fonts/OpenSans-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Lato'; + src: url('/fonts/Lato-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + /* ── Global component styles for Kerf Engine app ── */ /* File Upload Zone */ diff --git a/app/src/utils/__tests__/fontService.test.ts b/app/src/utils/__tests__/fontService.test.ts new file mode 100644 index 0000000..f3d807c --- /dev/null +++ b/app/src/utils/__tests__/fontService.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + loadFont, + loadFontByFamily, + textToPathData, + getAvailableFonts, + clearFontCache, + isFontCached, +} from '../fontService'; + +/** + * Load a real .ttf file from disk and return it as an ArrayBuffer. + * This lets us mock fetch() with real font data so opentype.js + * produces genuine glyph paths, proving the integration end-to-end. + */ +function readFontFile(filename: string): ArrayBuffer { + const fontPath = path.resolve(__dirname, '../../../public/fonts', filename); + const buffer = fs.readFileSync(fontPath); + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ); +} + +/** Create a mock fetch Response from an ArrayBuffer. */ +function mockFetchResponse(arrayBuffer: ArrayBuffer): Response { + return { + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(arrayBuffer), + } as unknown as Response; +} + +/** Create a mock fetch Response for an error. */ +function mockFetchError(status: number): Response { + return { + ok: false, + status, + arrayBuffer: () => Promise.reject(new Error('should not be called')), + } as unknown as Response; +} + +// Cache font buffers so we don't read from disk on every test +let latoBuffer: ArrayBuffer; +let robotoBuffer: ArrayBuffer; +let openSansBuffer: ArrayBuffer; + +beforeEach(() => { + // Read font files once (lazy — subsequent tests reuse the reference) + if (!latoBuffer) latoBuffer = readFontFile('Lato-Regular.ttf'); + if (!robotoBuffer) robotoBuffer = readFontFile('Roboto-Regular.ttf'); + if (!openSansBuffer) openSansBuffer = readFontFile('OpenSans-Regular.ttf'); +}); + +describe('fontService', () => { + beforeEach(() => { + clearFontCache(); + // Default mock: route any /fonts/*.ttf request to the correct buffer + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + if (url.includes('Lato')) + return Promise.resolve(mockFetchResponse(latoBuffer)); + if (url.includes('Roboto')) + return Promise.resolve(mockFetchResponse(robotoBuffer)); + if (url.includes('OpenSans')) + return Promise.resolve(mockFetchResponse(openSansBuffer)); + return Promise.resolve(mockFetchError(404)); + }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── getAvailableFonts ── + + describe('getAvailableFonts', () => { + it('returns at least 3 bundled fonts', () => { + const fonts = getAvailableFonts(); + expect(fonts.length).toBeGreaterThanOrEqual(3); + }); + + it('each font has family and file properties', () => { + for (const f of getAvailableFonts()) { + expect(f.family).toBeTruthy(); + expect(f.file).toMatch(/\.ttf$/); + } + }); + + it('includes Roboto, Open Sans, and Lato', () => { + const families = getAvailableFonts().map((f) => f.family); + expect(families).toContain('Roboto'); + expect(families).toContain('Open Sans'); + expect(families).toContain('Lato'); + }); + }); + + // ── loadFont ── + + describe('loadFont', () => { + it('fetches and parses a font by URL', async () => { + const font = await loadFont('/fonts/Lato-Regular.ttf'); + expect(font).toBeDefined(); + expect(font.unitsPerEm).toBeGreaterThan(0); + }); + + it('caches the font after first load', async () => { + await loadFont('/fonts/Lato-Regular.ttf'); + expect(isFontCached('/fonts/Lato-Regular.ttf')).toBe(true); + // Second call should not trigger fetch again + await loadFont('/fonts/Lato-Regular.ttf'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('deduplicates concurrent requests for the same URL', async () => { + const p1 = loadFont('/fonts/Lato-Regular.ttf'); + const p2 = loadFont('/fonts/Lato-Regular.ttf'); + const [f1, f2] = await Promise.all([p1, p2]); + expect(f1).toBe(f2); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('throws on fetch failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(mockFetchError(500))), + ); + await expect(loadFont('/fonts/bad.ttf')).rejects.toThrow( + /Failed to fetch font/, + ); + }); + + it('clears cache via clearFontCache', async () => { + await loadFont('/fonts/Lato-Regular.ttf'); + expect(isFontCached('/fonts/Lato-Regular.ttf')).toBe(true); + clearFontCache(); + expect(isFontCached('/fonts/Lato-Regular.ttf')).toBe(false); + }); + }); + + // ── loadFontByFamily ── + + describe('loadFontByFamily', () => { + it('loads Roboto by family name', async () => { + const font = await loadFontByFamily('Roboto'); + expect(font).toBeDefined(); + expect(font.unitsPerEm).toBe(2048); + }); + + it('is case-insensitive', async () => { + const font = await loadFontByFamily('roboto'); + expect(font).toBeDefined(); + }); + + it('falls back to first bundled font for unknown families', async () => { + const font = await loadFontByFamily('UnknownFont'); + expect(font).toBeDefined(); + // Should have loaded the first bundled font (Roboto) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('Roboto'), + ); + }); + }); + + // ── textToPathData ── + + describe('textToPathData', () => { + it('produces non-empty path data for simple text', async () => { + const result = await textToPathData('Hello', 'Lato', 24); + expect(result.pathData).toBeTruthy(); + expect(result.pathData.length).toBeGreaterThan(10); + }); + + it('path data starts with M (moveto) command', async () => { + const result = await textToPathData('A', 'Lato', 24); + expect(result.pathData).toMatch(/^M/); + }); + + it('path data contains Z (closepath) commands', async () => { + const result = await textToPathData('O', 'Lato', 24); + expect(result.pathData).toContain('Z'); + }); + + it('returns positive width and height', async () => { + const result = await textToPathData('Hello', 'Lato', 24); + expect(result.width).toBeGreaterThan(0); + expect(result.height).toBeGreaterThan(0); + }); + + it('width scales with text length', async () => { + const short = await textToPathData('Hi', 'Lato', 24); + const long = await textToPathData('Hello World', 'Lato', 24); + expect(long.width).toBeGreaterThan(short.width); + }); + + it('height scales with font size', async () => { + const small = await textToPathData('A', 'Lato', 12); + const large = await textToPathData('A', 'Lato', 48); + expect(large.height).toBeGreaterThan(small.height); + // Height should scale roughly linearly with fontSize + const ratio = large.height / small.height; + expect(ratio).toBeCloseTo(4, 0); // 48/12 = 4 + }); + + it('letter spacing increases total width', async () => { + const normal = await textToPathData('Hello', 'Lato', 24, 0); + const spaced = await textToPathData('Hello', 'Lato', 24, 5); + // 4 inter-character gaps × 5px = 20px extra + expect(spaced.width).toBeGreaterThan(normal.width); + const diff = spaced.width - normal.width; + expect(diff).toBeCloseTo(20, 0); + }); + + it('negative letter spacing decreases total width', async () => { + const normal = await textToPathData('Hello', 'Lato', 24, 0); + const tight = await textToPathData('Hello', 'Lato', 24, -1); + expect(tight.width).toBeLessThan(normal.width); + }); + + it('produces valid path data for all bundled fonts', async () => { + for (const fontDesc of getAvailableFonts()) { + const result = await textToPathData('Test', fontDesc.family, 24); + expect(result.pathData).toBeTruthy(); + expect(result.width).toBeGreaterThan(0); + expect(result.height).toBeGreaterThan(0); + } + }); + + it('Y coordinates are positive (canvas coordinate system)', async () => { + const result = await textToPathData('A', 'Lato', 48); + // Extract all Y values from M and L commands + const yValues = [...result.pathData.matchAll(/[ML][\d.-]+ ([\d.-]+)/g)].map( + (m) => parseFloat(m[1]), + ); + // All Y values should be >= 0 (top-left origin, Y-down) + expect(yValues.length).toBeGreaterThan(0); + for (const y of yValues) { + expect(y).toBeGreaterThanOrEqual(0); + } + }); + + it('handles empty string gracefully', async () => { + const result = await textToPathData('', 'Lato', 24); + expect(result.pathData).toBe(''); + expect(result.width).toBe(0); + expect(result.height).toBeGreaterThan(0); // height is font-metric based + }); + + it('handles space-only string', async () => { + const result = await textToPathData(' ', 'Lato', 24); + // Spaces produce no visible glyphs but advance the cursor + expect(result.width).toBeGreaterThan(0); + }); + + it('returns consistent results for repeated calls (caching)', async () => { + const r1 = await textToPathData('Same', 'Lato', 24); + const r2 = await textToPathData('Same', 'Lato', 24); + expect(r1.pathData).toBe(r2.pathData); + expect(r1.width).toBe(r2.width); + expect(r1.height).toBe(r2.height); + }); + }); +}); diff --git a/app/src/utils/fontService.ts b/app/src/utils/fontService.ts new file mode 100644 index 0000000..49f3b20 --- /dev/null +++ b/app/src/utils/fontService.ts @@ -0,0 +1,207 @@ +/** + * Font loading, caching, and text-to-path conversion service. + * + * Uses opentype.js to parse .ttf files fetched from the public /fonts/ directory. + * Provides text-to-SVG-path conversion for the "Convert to Paths" feature. + */ + +// opentype.js ships without TS declarations — we declare the subset we use. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type OpentypeFont = any; + +/** Metadata for a bundled font available to the user. */ +export interface FontDescriptor { + /** Display name shown in the font picker */ + family: string; + /** Filename relative to /fonts/ (e.g. "Roboto-Regular.ttf") */ + file: string; +} + +/** Result from text-to-path conversion. */ +export interface TextPathResult { + /** SVG path `d` attribute data */ + pathData: string; + /** Total advance width in canvas pixels */ + width: number; + /** Line height in canvas pixels (ascender + descender) */ + height: number; +} + +/** Registry of bundled fonts. Add entries here when new fonts are added to public/fonts/. */ +const BUNDLED_FONTS: FontDescriptor[] = [ + { family: 'Roboto', file: 'Roboto-Regular.ttf' }, + { family: 'Open Sans', file: 'OpenSans-Regular.ttf' }, + { family: 'Lato', file: 'Lato-Regular.ttf' }, +]; + +/** In-memory cache of parsed Font objects keyed by font file path. */ +const fontCache = new Map(); + +/** In-flight fetch promises to deduplicate concurrent loads for the same font. */ +const loadingPromises = new Map>(); + +/** + * Return the list of bundled fonts available for selection. + */ +export function getAvailableFonts(): FontDescriptor[] { + return BUNDLED_FONTS; +} + +/** + * Resolve a font family display name to the bundled font URL. + * Falls back to the first bundled font if the family isn't found. + */ +function resolveFontUrl(family: string): string { + const desc = BUNDLED_FONTS.find( + (f) => f.family.toLowerCase() === family.toLowerCase(), + ); + const file = desc ? desc.file : BUNDLED_FONTS[0].file; + return `/fonts/${file}`; +} + +/** + * Load and parse a font from a URL. Returns a cached instance if available. + * Concurrent calls for the same URL are deduplicated. + */ +export async function loadFont(fontUrl: string): Promise { + // Return cached font if available + const cached = fontCache.get(fontUrl); + if (cached) return cached; + + // Deduplicate in-flight requests + const inflight = loadingPromises.get(fontUrl); + if (inflight) return inflight; + + const promise = (async () => { + const opentype = await import('opentype.js'); + const response = await fetch(fontUrl); + if (!response.ok) { + throw new Error(`Failed to fetch font: ${fontUrl} (${response.status})`); + } + const arrayBuffer = await response.arrayBuffer(); + const font = opentype.parse(arrayBuffer); + fontCache.set(fontUrl, font); + return font; + })(); + + loadingPromises.set(fontUrl, promise); + + try { + const font = await promise; + return font; + } finally { + loadingPromises.delete(fontUrl); + } +} + +/** + * Load a font by its family display name (e.g. "Roboto"). + * Resolves the family name to the bundled font URL, then loads it. + */ +export async function loadFontByFamily(family: string): Promise { + const url = resolveFontUrl(family); + return loadFont(url); +} + +/** + * Convert text to SVG path data using opentype.js. + * + * Handles per-character glyph positioning with manual x-advance to support + * letter spacing (which opentype's `getPath()` does not natively support). + * + * The returned path data uses a coordinate system suitable for SVG/Konva: + * - origin at top-left of the text bounding box + * - Y increases downward (font coordinates are flipped from Y-up to Y-down) + * + * @param text The string to convert + * @param family Font family name (resolved to a bundled font) + * @param fontSize Font size in pixels + * @param letterSpacing Extra spacing between characters in pixels (default 0) + * @returns Path data, dimensions + */ +export async function textToPathData( + text: string, + family: string, + fontSize: number, + letterSpacing = 0, +): Promise { + const font = await loadFontByFamily(family); + const scale = fontSize / font.unitsPerEm; + + // Collect per-character path commands with manual x-advance for letter spacing + let cursorX = 0; + const allCommands: Array<{ type: string; x?: number; y?: number; x1?: number; y1?: number; x2?: number; y2?: number }> = []; + + for (let i = 0; i < text.length; i++) { + const glyph = font.charToGlyph(text[i]); + // getPath returns path at font units, positioned at (x, y) at the given fontSize + // y=0 is the baseline — ascenders go negative, descenders go positive in font coords + const glyphPath = glyph.getPath(cursorX / scale, 0, font.unitsPerEm); + + for (const cmd of glyphPath.commands) { + allCommands.push(cmd); + } + + // Advance cursor: glyph advance width scaled + letter spacing + const advance = (glyph.advanceWidth ?? 0) * scale; + cursorX += advance + letterSpacing; + } + + // Compute metrics for the ascender offset + const ascender = font.ascender * scale; + + // Build SVG path data string, flipping Y and offsetting by ascender + // Font coords: Y-up, baseline at 0, ascenders negative + // Canvas coords: Y-down, top-left origin + // Transform: canvas_y = ascender - font_y (mapped through scale) + let pathData = ''; + for (const cmd of allCommands) { + const yFlip = (y: number) => ascender - y * scale; + const xScale = (x: number) => x * scale; + + switch (cmd.type) { + case 'M': + pathData += `M${round(xScale(cmd.x!))} ${round(yFlip(cmd.y!))}`; + break; + case 'L': + pathData += `L${round(xScale(cmd.x!))} ${round(yFlip(cmd.y!))}`; + break; + case 'Q': + pathData += `Q${round(xScale(cmd.x1!))} ${round(yFlip(cmd.y1!))} ${round(xScale(cmd.x!))} ${round(yFlip(cmd.y!))}`; + break; + case 'C': + pathData += `C${round(xScale(cmd.x1!))} ${round(yFlip(cmd.y1!))} ${round(xScale(cmd.x2!))} ${round(yFlip(cmd.y2!))} ${round(xScale(cmd.x!))} ${round(yFlip(cmd.y!))}`; + break; + case 'Z': + pathData += 'Z'; + break; + } + } + + // Total dimensions + const descender = Math.abs(font.descender * scale); + const width = cursorX > 0 ? cursorX - letterSpacing : 0; // remove trailing spacing + const height = ascender + descender; + + return { pathData, width: round(width), height: round(height) }; +} + +/** Round to 2 decimal places to keep path data compact. */ +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Clear the font cache. Useful for testing. + */ +export function clearFontCache(): void { + fontCache.clear(); + loadingPromises.clear(); +} + +/** + * Check if a font is already cached by URL. + */ +export function isFontCached(fontUrl: string): boolean { + return fontCache.has(fontUrl); +}