test: Added TextObject to CanvasObject union and wired text tool into K…

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

GSD-Task: S03/T02
This commit is contained in:
jlightner 2026-03-26 05:55:47 +00:00
parent ff246b3d52
commit 40690e9c80
11 changed files with 238 additions and 16 deletions

View file

@ -26,3 +26,4 @@
{"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"}
{"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"}

View file

@ -19,7 +19,7 @@ This is the riskiest piece of the slice — if opentype.js path extraction doesn
- 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.
- [x] **T02: Added TextObject to CanvasObject union and wired text tool into KonvaStage rendering, CanvasToolbar, ObjectPanel, ShapeProperties, and AlignmentBar with exhaustive switch coverage** — 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)

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M002/S03/T01",
"timestamp": 1774504384634,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd app",
"exitCode": 0,
"durationMs": 4,
"verdict": "pass"
},
{
"command": "npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose",
"exitCode": 0,
"durationMs": 1408,
"verdict": "pass"
},
{
"command": "npx tsc --noEmit",
"exitCode": 1,
"durationMs": 734,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,86 @@
---
id: T02
parent: S03
milestone: M002
provides: []
requires: []
affects: []
key_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", "app/src/components/canvas/AlignmentBar.tsx"]
key_decisions: ["Text objects default to fill '#000000' and stroke 'transparent' (opposite of shape defaults) for natural text appearance", "Text transform scales width only (for wrapping), keeping fontSize unchanged — consistent with design tool UX"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Ran `cd app && npx tsc --noEmit` — zero errors under strict mode. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.45s."
completed_at: 2026-03-26T05:55:43.833Z
blocker_discovered: false
---
# T02: Added TextObject to CanvasObject union and wired text tool into KonvaStage rendering, CanvasToolbar, ObjectPanel, ShapeProperties, and AlignmentBar with exhaustive switch coverage
> Added TextObject to CanvasObject union and wired text tool into KonvaStage rendering, CanvasToolbar, ObjectPanel, ShapeProperties, and AlignmentBar with exhaustive switch coverage
## What Happened
---
id: T02
parent: S03
milestone: M002
key_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
- app/src/components/canvas/AlignmentBar.tsx
key_decisions:
- Text objects default to fill '#000000' and stroke 'transparent' (opposite of shape defaults) for natural text appearance
- Text transform scales width only (for wrapping), keeping fontSize unchanged — consistent with design tool UX
duration: ""
verification_result: passed
completed_at: 2026-03-26T05:55:43.846Z
blocker_discovered: false
---
# T02: Added TextObject to CanvasObject union and wired text tool into KonvaStage rendering, CanvasToolbar, ObjectPanel, ShapeProperties, and AlignmentBar with exhaustive switch coverage
**Added TextObject to CanvasObject union and wired text tool into KonvaStage rendering, CanvasToolbar, ObjectPanel, ShapeProperties, and AlignmentBar with exhaustive switch coverage**
## What Happened
Added a TextObject interface to canvas.ts extending BaseCanvasObject with text-specific fields (text, fontFamily, fontSize, letterSpacing, lineHeight, fill, stroke, strokeWidth, width for wrapping). Added it to the CanvasObject discriminated union. Then wired the text type through all six canvas component files: KonvaStage (rendering, tool creation, sizing, transform), CanvasToolbar (TOOLS array), ObjectPanel (TYPE_ICONS), ShapeProperties (getWidth/getHeight + hasFill), and AlignmentBar (toBoundingRect). All switch statements are exhaustive for the updated union. TypeScript compiles cleanly and all 95 tests pass.
## Verification
Ran `cd app && npx tsc --noEmit` — zero errors under strict mode. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.45s.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2000ms |
| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2450ms |
## Deviations
Added case 'text' to AlignmentBar.tsx toBoundingRect — not listed in task plan but required for exhaustive switch coverage.
## Known Issues
None.
## Files Created/Modified
- `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`
## Deviations
Added case 'text' to AlignmentBar.tsx toBoundingRect — not listed in task plan but required for exhaustive switch coverage.
## Known Issues
None.

View file

@ -1,6 +1,6 @@
{
"version": 1,
"exported_at": "2026-03-26T05:52:47.299Z",
"exported_at": "2026-03-26T05:55:43.881Z",
"milestones": [
{
"id": "M001",
@ -1329,19 +1329,29 @@
"milestone_id": "M002",
"slice_id": "S03",
"id": "T02",
"title": "Add TextObject type and wire text tool into canvas rendering",
"status": "pending",
"one_liner": "",
"narrative": "",
"verification_result": "",
"title": "Added TextObject to CanvasObject union and wired text tool into KonvaStage rendering, CanvasToolbar, ObjectPanel, ShapeProperties, and AlignmentBar with exhaustive switch coverage",
"status": "complete",
"one_liner": "Added TextObject to CanvasObject union and wired text tool into KonvaStage rendering, CanvasToolbar, ObjectPanel, ShapeProperties, and AlignmentBar with exhaustive switch coverage",
"narrative": "Added a TextObject interface to canvas.ts extending BaseCanvasObject with text-specific fields (text, fontFamily, fontSize, letterSpacing, lineHeight, fill, stroke, strokeWidth, width for wrapping). Added it to the CanvasObject discriminated union. Then wired the text type through all six canvas component files: KonvaStage (rendering, tool creation, sizing, transform), CanvasToolbar (TOOLS array), ObjectPanel (TYPE_ICONS), ShapeProperties (getWidth/getHeight + hasFill), and AlignmentBar (toBoundingRect). All switch statements are exhaustive for the updated union. TypeScript compiles cleanly and all 95 tests pass.",
"verification_result": "Ran `cd app && npx tsc --noEmit` — zero errors under strict mode. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.45s.",
"duration": "",
"completed_at": null,
"completed_at": "2026-03-26T05:55:43.833Z",
"blocker_discovered": false,
"deviations": "",
"known_issues": "",
"key_files": [],
"key_decisions": [],
"full_summary_md": "",
"deviations": "Added case 'text' to AlignmentBar.tsx toBoundingRect — not listed in task plan but required for exhaustive switch coverage.",
"known_issues": "None.",
"key_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",
"app/src/components/canvas/AlignmentBar.tsx"
],
"key_decisions": [
"Text objects default to fill '#000000' and stroke 'transparent' (opposite of shape defaults) for natural text appearance",
"Text transform scales width only (for wrapping), keeping fontSize unchanged — consistent with design tool UX"
],
"full_summary_md": "---\nid: T02\nparent: S03\nmilestone: M002\nkey_files:\n - app/src/types/canvas.ts\n - app/src/components/canvas/KonvaStage.tsx\n - app/src/components/canvas/CanvasToolbar.tsx\n - app/src/components/canvas/ObjectPanel.tsx\n - app/src/components/canvas/ShapeProperties.tsx\n - app/src/components/canvas/AlignmentBar.tsx\nkey_decisions:\n - Text objects default to fill '#000000' and stroke 'transparent' (opposite of shape defaults) for natural text appearance\n - Text transform scales width only (for wrapping), keeping fontSize unchanged — consistent with design tool UX\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T05:55:43.846Z\nblocker_discovered: false\n---\n\n# T02: Added TextObject to CanvasObject union and wired text tool into KonvaStage rendering, CanvasToolbar, ObjectPanel, ShapeProperties, and AlignmentBar with exhaustive switch coverage\n\n**Added TextObject to CanvasObject union and wired text tool into KonvaStage rendering, CanvasToolbar, ObjectPanel, ShapeProperties, and AlignmentBar with exhaustive switch coverage**\n\n## What Happened\n\nAdded a TextObject interface to canvas.ts extending BaseCanvasObject with text-specific fields (text, fontFamily, fontSize, letterSpacing, lineHeight, fill, stroke, strokeWidth, width for wrapping). Added it to the CanvasObject discriminated union. Then wired the text type through all six canvas component files: KonvaStage (rendering, tool creation, sizing, transform), CanvasToolbar (TOOLS array), ObjectPanel (TYPE_ICONS), ShapeProperties (getWidth/getHeight + hasFill), and AlignmentBar (toBoundingRect). All switch statements are exhaustive for the updated union. TypeScript compiles cleanly and all 95 tests pass.\n\n## Verification\n\nRan `cd app && npx tsc --noEmit` — zero errors under strict mode. Ran `cd app && npx vitest run --reporter=verbose` — all 95 tests pass across 7 test files in 2.45s.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2000ms |\n| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2450ms |\n\n\n## Deviations\n\nAdded case 'text' to AlignmentBar.tsx toBoundingRect — not listed in task plan but required for exhaustive switch coverage.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `app/src/types/canvas.ts`\n- `app/src/components/canvas/KonvaStage.tsx`\n- `app/src/components/canvas/CanvasToolbar.tsx`\n- `app/src/components/canvas/ObjectPanel.tsx`\n- `app/src/components/canvas/ShapeProperties.tsx`\n- `app/src/components/canvas/AlignmentBar.tsx`\n",
"description": "Extend the CanvasObject discriminated union with a TextObject type. Wire text objects into all canvas components: KonvaStage (render via Konva Text + case in getObjWidth/getObjHeight + text tool creation in handleStageMouseDown), CanvasToolbar (add 'text' to CanvasTool union and TOOLS array), ObjectPanel (add 'text' icon to TYPE_ICONS), and DesignCanvas (pass through). This follows the exact pattern established in S02 for adding object types.\n\n## Key Implementation Details\n- TextObject interface: extends BaseCanvasObject with type: 'text', text: string, fontFamily: string, fontSize: number, letterSpacing: number, lineHeight: number, fill: string, stroke: string, strokeWidth: number, width: number (for wrapping)\n- CanvasTool union in KonvaStage.tsx must add 'text' — this is imported by CanvasToolbar\n- KonvaStage renderObject switch: case 'text' renders <Text> from react-konva with fontFamily, fontSize, fill, stroke, strokeWidth, width, letterSpacing, lineHeight props\n- KonvaStage handleStageMouseDown switch: case 'text' creates a TextObject with defaults (text: 'Text', fontFamily: 'Roboto', fontSize: 24, letterSpacing: 0, lineHeight: 1.2)\n- KonvaStage getObjWidth for text: use obj.width (wrapping width) or estimate from fontSize * text.length * 0.6\n- KonvaStage getObjHeight for text: obj.fontSize * obj.lineHeight\n- KonvaStage onTransformEnd for text: scale width, keep fontSize unchanged (text wraps to new width)\n- ObjectPanel TYPE_ICONS: text → 'T'\n- ShapeProperties getWidth/getHeight: add case 'text' returning obj.width and obj.fontSize * obj.lineHeight\n- All switch statements must be exhaustive — TypeScript noFallthroughCasesInSwitch enforces this\n- TypeScript strict mode — no unused locals/params, erasableSyntaxOnly, use import type",
"estimate": "1h",
"files": [
@ -1858,6 +1868,28 @@
"verdict": "✅ pass",
"duration_ms": 100,
"created_at": "2026-03-26T05:52:47.243Z"
},
{
"id": 34,
"task_id": "T02",
"slice_id": "S03",
"milestone_id": "M002",
"command": "cd app && npx tsc --noEmit",
"exit_code": 0,
"verdict": "✅ pass",
"duration_ms": 2000,
"created_at": "2026-03-26T05:55:43.833Z"
},
{
"id": 35,
"task_id": "T02",
"slice_id": "S03",
"milestone_id": "M002",
"command": "cd app && npx vitest run --reporter=verbose",
"exit_code": 0,
"verdict": "✅ pass",
"duration_ms": 2450,
"created_at": "2026-03-26T05:55:43.833Z"
}
]
}

View file

@ -48,6 +48,10 @@ function toBoundingRect(obj: CanvasObject): BoundingRect {
h = Math.max(...ys) - Math.min(...ys);
break;
}
case 'text':
w = obj.width;
h = obj.fontSize * obj.lineHeight;
break;
}
return { id: obj.id, x: obj.x, y: obj.y, width: w, height: h };
}

View file

@ -14,6 +14,7 @@ const TOOLS: { tool: CanvasTool; label: string; icon: string }[] = [
{ tool: 'circle', label: 'Circle', icon: '○' },
{ tool: 'ellipse', label: 'Ellipse', icon: '⬯' },
{ tool: 'line', label: 'Line', icon: '' },
{ tool: 'text', label: 'Text', icon: 'T' },
];
// -- Props --------------------------------------------------------------------

View file

@ -23,6 +23,7 @@ import {
Ellipse,
Line,
Image as KonvaImage,
Text as KonvaText,
Transformer,
Path,
} from 'react-konva';
@ -36,7 +37,7 @@ import { toPx, artboardClipPath } from '../../utils/artboardShapes';
// -- Types --------------------------------------------------------------------
export type CanvasTool = 'pointer' | 'rect' | 'circle' | 'ellipse' | 'line';
export type CanvasTool = 'pointer' | 'rect' | 'circle' | 'ellipse' | 'line' | 'text';
export interface KonvaStageProps {
width: number;
@ -247,6 +248,9 @@ export default function KonvaStage({
} else if (obj.type === 'ellipse') {
(changes as Record<string, unknown>).radiusX = Math.max(5, (node.width() * scaleX) / 2);
(changes as Record<string, unknown>).radiusY = Math.max(5, (node.height() * scaleY) / 2);
} else if (obj.type === 'text') {
// Scale width for wrapping, keep fontSize unchanged
(changes as Record<string, unknown>).width = Math.max(20, node.width() * scaleX);
}
onUpdateObject(obj.id, changes);
@ -315,6 +319,23 @@ export default function KonvaStage({
/>
);
case 'text':
return (
<KonvaText
key={obj.id}
{...commonProps}
text={obj.text}
fontFamily={obj.fontFamily}
fontSize={obj.fontSize}
fill={obj.fill}
stroke={obj.stroke}
strokeWidth={obj.strokeWidth}
width={obj.width}
letterSpacing={obj.letterSpacing}
lineHeight={obj.lineHeight}
/>
);
default:
return null;
}
@ -426,6 +447,29 @@ export default function KonvaStage({
dash: [],
};
break;
case 'text':
newObj = {
id: nextId('text'),
type: 'text',
name: 'Text',
x,
y,
rotation: 0,
visible: true,
locked: false,
opacity: 1,
text: 'Text',
fontFamily: 'Roboto',
fontSize: 24,
letterSpacing: 0,
lineHeight: 1.2,
fill: '#000000',
stroke: 'transparent',
strokeWidth: 0,
width: 200,
};
break;
}
if (newObj) {
@ -592,6 +636,8 @@ function getObjWidth(obj: CanvasObject): number {
case 'line':
return Math.max(...obj.points.filter((_, i) => i % 2 === 0)) -
Math.min(...obj.points.filter((_, i) => i % 2 === 0));
case 'text':
return obj.width || obj.fontSize * obj.text.length * 0.6;
}
}
@ -607,6 +653,8 @@ function getObjHeight(obj: CanvasObject): number {
case 'line':
return Math.max(...obj.points.filter((_, i) => i % 2 === 1)) -
Math.min(...obj.points.filter((_, i) => i % 2 === 1));
case 'text':
return obj.fontSize * obj.lineHeight;
}
}

View file

@ -17,6 +17,7 @@ const TYPE_ICONS: Record<CanvasObject['type'], string> = {
ellipse: '⬯',
line: '',
image: '🖼',
text: 'T',
};
// -- Props --------------------------------------------------------------------

View file

@ -23,6 +23,8 @@ function getWidth(obj: CanvasObject): number {
const xs = obj.points.filter((_, i) => i % 2 === 0);
return Math.round((Math.max(...xs) - Math.min(...xs)) * 100) / 100;
}
case 'text':
return Math.round(obj.width * 100) / 100;
}
}
@ -39,6 +41,8 @@ function getHeight(obj: CanvasObject): number {
const ys = obj.points.filter((_, i) => i % 2 === 1);
return Math.round((Math.max(...ys) - Math.min(...ys)) * 100) / 100;
}
case 'text':
return Math.round(obj.fontSize * obj.lineHeight * 100) / 100;
}
}
@ -62,7 +66,7 @@ export default function ShapeProperties({
onUpdate,
}: ShapePropertiesProps) {
const hasStroke = object.type !== 'image';
const hasFill = object.type === 'rect' || object.type === 'circle' || object.type === 'ellipse';
const hasFill = object.type === 'rect' || object.type === 'circle' || object.type === 'ellipse' || object.type === 'text';
const isLine = object.type === 'line';
const handleChange = useCallback(

View file

@ -82,12 +82,27 @@ export interface ImageObject extends BaseCanvasObject {
src: string;
}
export interface TextObject extends BaseCanvasObject {
type: 'text';
text: string;
fontFamily: string;
fontSize: number;
letterSpacing: number;
lineHeight: number;
fill: string;
stroke: string;
strokeWidth: number;
/** Wrapping width for text layout. */
width: number;
}
export type CanvasObject =
| RectObject
| CircleObject
| EllipseObject
| LineObject
| ImageObject;
| ImageObject
| TextObject;
export type CanvasObjectType = CanvasObject['type'];