---
id: S03
parent: M002
milestone: M002
provides:
  - TextObject type in CanvasObject discriminated union
  - fontService: loadFont, loadFontByFamily, textToPathData, getAvailableFonts, clearFontCache
  - Text tool in CanvasToolbar
  - Text property controls in ShapeProperties
  - Convert to Paths action (text → image via SVG Blob URL)
  - 3 bundled OFL Google Fonts (Roboto, Open Sans, Lato)
requires:
  - slice: S02
    provides: CanvasObject discriminated union, KonvaStage rendering pipeline, ShapeProperties component, CanvasToolbar, ObjectPanel, AlignmentBar, useCanvasState hook
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/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
  - app/src/components/canvas/AlignmentBar.tsx
  - app/src/views/DesignCanvas.tsx
  - app/src/App.css
key_decisions:
  - opentype.js v1.3.4 with dynamic import() and local type declarations (D008)
  - Y-axis flip formula for canvas-compatible SVG path coordinates
  - Manual per-character glyph positioning for letter spacing support
  - Single onConvertToPath callback for atomic text-to-image replacement (D009)
  - Text objects default fill #000000 and stroke transparent
  - Text transform scales width only, keeping fontSize unchanged
patterns_established:
  - P009: opentype.js needs dynamic import() and local type declarations
  - P010: Font Y-axis flip: canvas_y = ascender - font_y * scale
  - P011: Letter spacing requires manual per-character glyph positioning
  - P012: Adding new CanvasObject types requires exhaustive switch updates in 6 files
observability_surfaces:
  - none
drill_down_paths:
  - .gsd/milestones/M002/slices/S03/tasks/T01-SUMMARY.md
  - .gsd/milestones/M002/slices/S03/tasks/T02-SUMMARY.md
  - .gsd/milestones/M002/slices/S03/tasks/T03-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-03-26T06:00:46.863Z
blocker_discovered: false
---

# S03: Text System + Font Loading

**Added text objects to the design canvas with fontService (opentype.js-powered font loading, caching, text-to-path conversion), text tool in toolbar, text-specific property controls, and Convert to Paths action — 24 fontService tests + all 95 app tests pass**

## What Happened

This slice delivered the complete text system for the design canvas in three tasks.

**T01 — fontService foundation.** Installed opentype.js v1.3.4 and built `fontService.ts` — a utility module that fetches .ttf font files from the Vite public directory, parses them with opentype.js, caches parsed Font objects with in-flight request deduplication, and converts text strings to SVG path data. The text-to-path conversion handles the font-to-canvas coordinate system transformation (Y-axis flip: `ascender * scale - font_y`) and implements manual per-character glyph positioning to support letter spacing (opentype.js's `getPath()` doesn't support it natively). Three OFL-licensed Google Fonts (Roboto, Open Sans, Lato) were bundled as .ttf files in `app/public/fonts/` with corresponding `@font-face` declarations in App.css. 24 unit tests cover the font registry, loading/caching/deduplication/error handling, family resolution, and text-to-path conversion including dimension scaling, letter spacing arithmetic, coordinate system validation, and edge cases.

**T02 — TextObject type and canvas integration.** Added the `TextObject` interface to the `CanvasObject` discriminated union in `canvas.ts` with text-specific fields (text, fontFamily, fontSize, letterSpacing, lineHeight, fill, stroke, strokeWidth, width). Wired the text type through all six canvas component files: KonvaStage (rendering via `<Text>`, tool creation, sizing, transform that scales width only), CanvasToolbar (added 'text' to TOOLS array), ObjectPanel (added 'T' icon), ShapeProperties (getWidth/getHeight cases), and AlignmentBar (toBoundingRect case). All switch statements maintain exhaustive coverage under TypeScript strict mode. Text objects default to fill `#000000` and stroke `transparent` for natural text appearance.

**T03 — Text property controls and Convert to Paths.** Extended ShapeProperties with text-specific controls that render when the selected object has type 'text': textarea for content, font family dropdown populated from `fontService.getAvailableFonts()`, and numeric inputs for font size (8–200), letter spacing (-5–20), and line height (0.5–3). Added a "Convert to Paths" button that calls `fontService.textToPathData()`, wraps the result in an SVG string, creates a Blob URL, and replaces the TextObject with an ImageObject via a new `onConvertToPath` callback. The conversion shows a confirmation dialog (destructive action) and a "Converting…" disabled state during the async operation. DesignCanvas passes the handler that atomically removes the text object and adds the replacement image.

## Verification

TypeScript: `npx tsc --noEmit` — zero errors under strict mode with verbatimModuleSyntax, noUnusedLocals, noUnusedParameters. Tests: `npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files (24 fontService tests + 71 existing tests). Font files: 3 .ttf files confirmed in app/public/fonts/.

## Requirements Advanced

None.

## Requirements Validated

None.

## New Requirements Surfaced

None.

## Requirements Invalidated or Re-scoped

None.

## Deviations

T02 added case 'text' to AlignmentBar.tsx toBoundingRect — not listed in task plan but required for exhaustive switch coverage. T03 used a single onConvertToPath(textObjectId, imageObject) callback instead of separate onAddObject + onRemoveObject as the plan suggested — simpler and keeps the replacement atomic.

## Known Limitations

Convert-to-paths produces an ImageObject with a Blob URL — the SVG path data is embedded in the URL, not stored as a first-class path object type. Multi-line text is supported by Konva's text wrapping (width property) but the text-to-path conversion treats input as a single line. Font loading requires network fetch of .ttf files — no offline/preload strategy beyond browser caching.

## Follow-ups

None.

## Files Created/Modified

- `app/src/utils/fontService.ts` — New — font loading, caching, text-to-path conversion service using opentype.js
- `app/src/utils/__tests__/fontService.test.ts` — New — 24 unit tests for fontService (registry, loading, caching, path conversion)
- `app/public/fonts/Roboto-Regular.ttf` — New — bundled OFL-licensed Roboto variable font
- `app/public/fonts/OpenSans-Regular.ttf` — New — bundled OFL-licensed Open Sans variable font
- `app/public/fonts/Lato-Regular.ttf` — New — bundled OFL-licensed Lato font
- `app/src/App.css` — Modified — added @font-face declarations for Roboto, Open Sans, Lato
- `app/package.json` — Modified — added opentype.js v1.3.4 dependency
- `app/src/types/canvas.ts` — Modified — added TextObject interface and extended CanvasObject union with 'text' type
- `app/src/components/canvas/KonvaStage.tsx` — Modified — text rendering, text tool creation, text sizing/transform, 'text' in CanvasTool union
- `app/src/components/canvas/CanvasToolbar.tsx` — Modified — added text tool to TOOLS array
- `app/src/components/canvas/ObjectPanel.tsx` — Modified — added 'T' icon for text objects in TYPE_ICONS
- `app/src/components/canvas/ShapeProperties.tsx` — Modified — text property controls (content, font family, size, letter spacing, line height), Convert to Paths button, getWidth/getHeight cases
- `app/src/components/canvas/AlignmentBar.tsx` — Modified — added text case to toBoundingRect for exhaustive switch
- `app/src/views/DesignCanvas.tsx` — Modified — added onConvertToPath handler wiring ShapeProperties to canvas state
