test: Built fontService with opentype.js font loading, caching, text-to…

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

GSD-Task: S03/T01
This commit is contained in:
jlightner 2026-03-26 05:53:04 +00:00
parent 4215ef7b8c
commit ff246b3d52
21 changed files with 1369 additions and 12 deletions

View file

@ -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 | | D004 | | architecture | Engine Docker image build strategy | Multi-stage build: builder stage compiles pypotrace with build-essential/libagg-dev/libpotrace-dev, runtime stage uses python:3.11-slim with only runtime libs (libpotrace0, libagg2, curl). Only engine source is copied — no App code. | Multi-stage keeps the runtime image small by excluding build tools. Copying only engine source enforces the Engine/App separation (D001). curl is included for Docker HEALTHCHECK. | Yes | agent |
| D005 | | architecture | Frontend technology stack for Kerf App | Vite + React + TypeScript with plain CSS modules, Vitest for testing, raw fetch + FormData for engine API calls, no UI framework for V1 | Minimal dependency surface for a tool that needs fast iteration. The engine API uses multipart/form-data which works naturally with native fetch + FormData. CSS modules avoid global style conflicts without adding a framework. Vitest integrates natively with Vite's transform pipeline. No UI library needed — the app has a focused interface with ~8 components. | Yes | agent | | D005 | | architecture | Frontend technology stack for Kerf App | Vite + React + TypeScript with plain CSS modules, Vitest for testing, raw fetch + FormData for engine API calls, no UI framework for V1 | Minimal dependency surface for a tool that needs fast iteration. The engine API uses multipart/form-data which works naturally with native fetch + FormData. CSS modules avoid global style conflicts without adding a framework. Vitest integrates natively with Vite's transform pipeline. No UI library needed — the app has a focused interface with ~8 components. | Yes | agent |
| D006 | | frontend | React hook params stabilization strategy for useDebouncedTrace | JSON.stringify params in useEffect deps array, single-effect architecture | useCallback-based triggerTrace with object params caused infinite re-render loops because React creates new object references each render. JSON.stringify converts params to a stable string for dependency comparison, eliminating the need for callers to useMemo their params objects. Simpler API surface and no re-render bugs. | Yes | agent | | 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 |

View file

@ -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":"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":"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-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"}

View file

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

View file

@ -0,0 +1,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

View file

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

View file

@ -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
}

View file

@ -1,6 +1,67 @@
# S03: Text System + Font Loading # 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 **Demo:** After this: Add text objects, select fonts from picker, change size/spacing, convert text to paths — paths match original glyphs
## Tasks ## 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 <Text> 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: `<svg xmlns=... viewBox=...><path d="{pathData}" fill="{obj.fill}" stroke="{obj.stroke}" stroke-width="{obj.strokeWidth}"/></svg>`
- 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

View file

@ -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.

View file

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

View file

@ -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.

View file

@ -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 <Text> 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

View file

@ -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: `<svg xmlns=... viewBox=...><path d="{pathData}" fill="{obj.fill}" stroke="{obj.stroke}" stroke-width="{obj.strokeWidth}"/></svg>`
- 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

File diff suppressed because one or more lines are too long

29
app/package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"konva": "^10.2.3", "konva": "^10.2.3",
"opentype.js": "^1.3.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-konva": "^19.2.3" "react-konva": "^19.2.3"
@ -3202,6 +3203,22 @@
], ],
"license": "MIT" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3637,6 +3654,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@ -3683,6 +3706,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View file

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"konva": "^10.2.3", "konva": "^10.2.3",
"opentype.js": "^1.3.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-konva": "^19.2.3" "react-konva": "^19.2.3"

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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 ── */ /* ── Global component styles for Kerf Engine app ── */
/* File Upload Zone */ /* File Upload Zone */

View file

@ -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);
});
});
});

View file

@ -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<string, OpentypeFont>();
/** In-flight fetch promises to deduplicate concurrent loads for the same font. */
const loadingPromises = new Map<string, Promise<OpentypeFont>>();
/**
* 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<OpentypeFont> {
// 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<OpentypeFont> {
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<TextPathResult> {
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);
}