diff --git a/.gsd/event-log.jsonl b/.gsd/event-log.jsonl
index 860d619..f1baa35 100644
--- a/.gsd/event-log.jsonl
+++ b/.gsd/event-log.jsonl
@@ -27,3 +27,4 @@
{"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"}
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S03","taskId":"T02"},"ts":"2026-03-26T05:55:43.882Z","actor":"agent","hash":"e4c20836b9c0ee25","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
+{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S03","taskId":"T03"},"ts":"2026-03-26T05:58:01.944Z","actor":"agent","hash":"61f3bf5bb0b4c33f","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
diff --git a/.gsd/milestones/M002/slices/S03/S03-PLAN.md b/.gsd/milestones/M002/slices/S03/S03-PLAN.md
index e5ec860..139fd12 100644
--- a/.gsd/milestones/M002/slices/S03/S03-PLAN.md
+++ b/.gsd/milestones/M002/slices/S03/S03-PLAN.md
@@ -36,7 +36,7 @@ This is the riskiest piece of the slice — if opentype.js path extraction doesn
- 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().
+- [x] **T03: Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas** — 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:
diff --git a/.gsd/milestones/M002/slices/S03/tasks/T02-VERIFY.json b/.gsd/milestones/M002/slices/S03/tasks/T02-VERIFY.json
new file mode 100644
index 0000000..cbf5226
--- /dev/null
+++ b/.gsd/milestones/M002/slices/S03/tasks/T02-VERIFY.json
@@ -0,0 +1,30 @@
+{
+ "schemaVersion": 1,
+ "taskId": "T02",
+ "unitId": "M002/S03/T02",
+ "timestamp": 1774504547344,
+ "passed": false,
+ "discoverySource": "task-plan",
+ "checks": [
+ {
+ "command": "cd app",
+ "exitCode": 0,
+ "durationMs": 4,
+ "verdict": "pass"
+ },
+ {
+ "command": "npx tsc --noEmit",
+ "exitCode": 1,
+ "durationMs": 784,
+ "verdict": "fail"
+ },
+ {
+ "command": "npx vitest run --reporter=verbose",
+ "exitCode": 1,
+ "durationMs": 1590,
+ "verdict": "fail"
+ }
+ ],
+ "retryAttempt": 1,
+ "maxRetries": 2
+}
diff --git a/.gsd/milestones/M002/slices/S03/tasks/T03-SUMMARY.md b/.gsd/milestones/M002/slices/S03/tasks/T03-SUMMARY.md
new file mode 100644
index 0000000..c7b48d2
--- /dev/null
+++ b/.gsd/milestones/M002/slices/S03/tasks/T03-SUMMARY.md
@@ -0,0 +1,78 @@
+---
+id: T03
+parent: S03
+milestone: M002
+provides: []
+requires: []
+affects: []
+key_files: ["app/src/components/canvas/ShapeProperties.tsx", "app/src/views/DesignCanvas.tsx"]
+key_decisions: ["Convert-to-paths uses a single onConvertToPath(textObjectId, imageObject) callback rather than separate onAddObject + onRemoveObject — simpler prop interface, atomic replacement", "Convert-to-paths generates an SVG Blob URL with the text path data and creates an ImageObject at the same position — reuses existing image rendering pipeline"]
+patterns_established: []
+drill_down_paths: []
+observability_surfaces: []
+duration: ""
+verification_result: "Ran `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.2s."
+completed_at: 2026-03-26T05:58:01.901Z
+blocker_discovered: false
+---
+
+# T03: Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas
+
+> Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas
+
+## What Happened
+---
+id: T03
+parent: S03
+milestone: M002
+key_files:
+ - app/src/components/canvas/ShapeProperties.tsx
+ - app/src/views/DesignCanvas.tsx
+key_decisions:
+ - Convert-to-paths uses a single onConvertToPath(textObjectId, imageObject) callback rather than separate onAddObject + onRemoveObject — simpler prop interface, atomic replacement
+ - Convert-to-paths generates an SVG Blob URL with the text path data and creates an ImageObject at the same position — reuses existing image rendering pipeline
+duration: ""
+verification_result: passed
+completed_at: 2026-03-26T05:58:01.911Z
+blocker_discovered: false
+---
+
+# T03: Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas
+
+**Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas**
+
+## What Happened
+
+Extended ShapeProperties with text-specific controls that render when the selected object has type 'text': a textarea for text content, a font family dropdown populated from fontService.getAvailableFonts(), 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() to convert the text to SVG path data, wraps it in an SVG string, creates a Blob URL, and replaces the TextObject with an ImageObject at the same position via a new onConvertToPath callback prop. The button shows a window.confirm dialog before proceeding (destructive action) and displays a "Converting…" disabled state during the async operation. Updated DesignCanvas to pass the onConvertToPath handler that removes the text object, adds the new image object, and selects it.
+
+## Verification
+
+Ran `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.2s.
+
+## Verification Evidence
+
+| # | Command | Exit Code | Verdict | Duration |
+|---|---------|-----------|---------|----------|
+| 1 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2400ms |
+| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2200ms |
+
+
+## Deviations
+
+Used a single onConvertToPath(textObjectId, imageObject) callback instead of separate onAddObject + onRemoveObject callbacks as the task plan suggested — simpler and keeps the replacement atomic.
+
+## Known Issues
+
+None.
+
+## Files Created/Modified
+
+- `app/src/components/canvas/ShapeProperties.tsx`
+- `app/src/views/DesignCanvas.tsx`
+
+
+## Deviations
+Used a single onConvertToPath(textObjectId, imageObject) callback instead of separate onAddObject + onRemoveObject callbacks as the task plan suggested — simpler and keeps the replacement atomic.
+
+## Known Issues
+None.
diff --git a/.gsd/state-manifest.json b/.gsd/state-manifest.json
index abc0a05..a851c08 100644
--- a/.gsd/state-manifest.json
+++ b/.gsd/state-manifest.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "exported_at": "2026-03-26T05:55:43.881Z",
+ "exported_at": "2026-03-26T05:58:01.942Z",
"milestones": [
{
"id": "M001",
@@ -1384,19 +1384,25 @@
"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": "",
+ "title": "Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas",
+ "status": "complete",
+ "one_liner": "Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas",
+ "narrative": "Extended ShapeProperties with text-specific controls that render when the selected object has type 'text': a textarea for text content, a font family dropdown populated from fontService.getAvailableFonts(), 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() to convert the text to SVG path data, wraps it in an SVG string, creates a Blob URL, and replaces the TextObject with an ImageObject at the same position via a new onConvertToPath callback prop. The button shows a window.confirm dialog before proceeding (destructive action) and displays a \"Converting…\" disabled state during the async operation. Updated DesignCanvas to pass the onConvertToPath handler that removes the text object, adds the new image object, and selects it.",
+ "verification_result": "Ran `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.2s.",
"duration": "",
- "completed_at": null,
+ "completed_at": "2026-03-26T05:58:01.901Z",
"blocker_discovered": false,
- "deviations": "",
- "known_issues": "",
- "key_files": [],
- "key_decisions": [],
- "full_summary_md": "",
+ "deviations": "Used a single onConvertToPath(textObjectId, imageObject) callback instead of separate onAddObject + onRemoveObject callbacks as the task plan suggested — simpler and keeps the replacement atomic.",
+ "known_issues": "None.",
+ "key_files": [
+ "app/src/components/canvas/ShapeProperties.tsx",
+ "app/src/views/DesignCanvas.tsx"
+ ],
+ "key_decisions": [
+ "Convert-to-paths uses a single onConvertToPath(textObjectId, imageObject) callback rather than separate onAddObject + onRemoveObject — simpler prop interface, atomic replacement",
+ "Convert-to-paths generates an SVG Blob URL with the text path data and creates an ImageObject at the same position — reuses existing image rendering pipeline"
+ ],
+ "full_summary_md": "---\nid: T03\nparent: S03\nmilestone: M002\nkey_files:\n - app/src/components/canvas/ShapeProperties.tsx\n - app/src/views/DesignCanvas.tsx\nkey_decisions:\n - Convert-to-paths uses a single onConvertToPath(textObjectId, imageObject) callback rather than separate onAddObject + onRemoveObject — simpler prop interface, atomic replacement\n - Convert-to-paths generates an SVG Blob URL with the text path data and creates an ImageObject at the same position — reuses existing image rendering pipeline\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T05:58:01.911Z\nblocker_discovered: false\n---\n\n# T03: Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas\n\n**Added text-specific property controls (content, font family, size, letter spacing, line height) and Convert to Paths button to ShapeProperties, wired through DesignCanvas**\n\n## What Happened\n\nExtended ShapeProperties with text-specific controls that render when the selected object has type 'text': a textarea for text content, a font family dropdown populated from fontService.getAvailableFonts(), 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() to convert the text to SVG path data, wraps it in an SVG string, creates a Blob URL, and replaces the TextObject with an ImageObject at the same position via a new onConvertToPath callback prop. The button shows a window.confirm dialog before proceeding (destructive action) and displays a \"Converting…\" disabled state during the async operation. Updated DesignCanvas to pass the onConvertToPath handler that removes the text object, adds the new image object, and selects it.\n\n## Verification\n\nRan `cd app && npx tsc --noEmit` — zero TypeScript errors. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.2s.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2400ms |\n| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2200ms |\n\n\n## Deviations\n\nUsed a single onConvertToPath(textObjectId, imageObject) callback instead of separate onAddObject + onRemoveObject callbacks as the task plan suggested — simpler and keeps the replacement atomic.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `app/src/components/canvas/ShapeProperties.tsx`\n- `app/src/views/DesignCanvas.tsx`\n",
"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": [
@@ -1890,6 +1896,28 @@
"verdict": "✅ pass",
"duration_ms": 2450,
"created_at": "2026-03-26T05:55:43.833Z"
+ },
+ {
+ "id": 36,
+ "task_id": "T03",
+ "slice_id": "S03",
+ "milestone_id": "M002",
+ "command": "cd app && npx tsc --noEmit",
+ "exit_code": 0,
+ "verdict": "✅ pass",
+ "duration_ms": 2400,
+ "created_at": "2026-03-26T05:58:01.901Z"
+ },
+ {
+ "id": 37,
+ "task_id": "T03",
+ "slice_id": "S03",
+ "milestone_id": "M002",
+ "command": "cd app && npx vitest run --reporter=verbose",
+ "exit_code": 0,
+ "verdict": "✅ pass",
+ "duration_ms": 2200,
+ "created_at": "2026-03-26T05:58:01.901Z"
}
]
}
\ No newline at end of file
diff --git a/app/src/components/canvas/ShapeProperties.tsx b/app/src/components/canvas/ShapeProperties.tsx
index f6bafc1..234d29b 100644
--- a/app/src/components/canvas/ShapeProperties.tsx
+++ b/app/src/components/canvas/ShapeProperties.tsx
@@ -3,10 +3,13 @@
*
* Shows stroke color, stroke weight, fill color (with toggle), dimensions.
* For line objects: line style dropdown (solid, dashed, dotted).
+ * For text objects: text content, font family, font size, letter spacing,
+ * line height, and a "Convert to Paths" action button.
*/
-import { useCallback } from 'react';
-import type { CanvasObject, LineStyle } from '../../types/canvas';
+import { useCallback, useState } from 'react';
+import type { CanvasObject, ImageObject, LineStyle, TextObject } from '../../types/canvas';
+import { getAvailableFonts, textToPathData } from '../../utils/fontService';
// -- Helpers ------------------------------------------------------------------
@@ -57,6 +60,8 @@ const DASH_PRESETS: Record = {
export interface ShapePropertiesProps {
object: CanvasObject;
onUpdate: (id: string, changes: Partial) => void;
+ /** Called to replace a text object with an image object (convert-to-paths). */
+ onConvertToPath?: (textObjectId: string, imageObject: ImageObject) => void;
}
// -- Component ----------------------------------------------------------------
@@ -64,10 +69,14 @@ export interface ShapePropertiesProps {
export default function ShapeProperties({
object,
onUpdate,
+ onConvertToPath,
}: ShapePropertiesProps) {
const hasStroke = object.type !== 'image';
const hasFill = object.type === 'rect' || object.type === 'circle' || object.type === 'ellipse' || object.type === 'text';
const isLine = object.type === 'line';
+ const isText = object.type === 'text';
+
+ const [converting, setConverting] = useState(false);
const handleChange = useCallback(
(changes: Partial) => {
@@ -76,6 +85,60 @@ export default function ShapeProperties({
[object.id, onUpdate],
);
+ /** Convert the current text object to an SVG image via fontService. */
+ const handleConvertToPath = useCallback(async () => {
+ if (object.type !== 'text' || !onConvertToPath) return;
+
+ const confirmed = window.confirm(
+ 'Convert text to paths? This replaces the editable text with a vector image and cannot be undone.',
+ );
+ if (!confirmed) return;
+
+ const textObj = object as TextObject;
+ setConverting(true);
+
+ try {
+ const result = await textToPathData(
+ textObj.text,
+ textObj.fontFamily,
+ textObj.fontSize,
+ textObj.letterSpacing,
+ );
+
+ // Build an SVG string from the path data
+ const svgString = [
+ ``,
+ ].join('');
+
+ // Create a Blob URL for the SVG
+ const blob = new Blob([svgString], { type: 'image/svg+xml' });
+ const src = URL.createObjectURL(blob);
+
+ const imageObj: ImageObject = {
+ type: 'image',
+ id: `path-${Date.now()}`,
+ name: `${textObj.name} (paths)`,
+ x: textObj.x,
+ y: textObj.y,
+ width: result.width,
+ height: result.height,
+ rotation: textObj.rotation,
+ visible: textObj.visible,
+ locked: textObj.locked,
+ opacity: textObj.opacity,
+ src,
+ };
+
+ onConvertToPath(textObj.id, imageObj);
+ } catch (err) {
+ console.error('Convert to paths failed:', err);
+ } finally {
+ setConverting(false);
+ }
+ }, [object, onConvertToPath]);
+
return (
Properties
@@ -98,6 +161,110 @@ export default function ShapeProperties({