Canvas foundation: SVG viewport, pan/zoom/pinch, geometry lib (#9) #29

Merged
steve merged 2 commits from phase-3-canvas into main 2026-07-19 00:18:28 +00:00
11 changed files with 1066 additions and 13 deletions
Showing only changes of commit 30b36b7033 - Show all commits
+415 -4
View File
@@ -10,11 +10,13 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.95.0", "@tanstack/react-router": "^1.95.0",
"@use-gesture/react": "^10.3.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"zod": "^3.24.1" "zod": "^3.24.1",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@@ -24,7 +26,8 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.7" "vite": "^6.0.7",
"vitest": "^4.1.10"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -1200,6 +1203,13 @@
"win32" "win32"
] ]
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1638,6 +1648,24 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1659,7 +1687,7 @@
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1675,6 +1703,24 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
"license": "MIT"
},
"node_modules/@use-gesture/react": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
"license": "MIT",
"dependencies": {
"@use-gesture/core": "10.3.1"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1696,6 +1742,129 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.43", "version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
@@ -1764,6 +1933,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1790,7 +1969,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@@ -1842,6 +2021,13 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1894,6 +2080,26 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2325,6 +2531,27 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/obug": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2487,6 +2714,13 @@
"seroval": "^1.0" "seroval": "^1.0"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2497,6 +2731,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwind-merge": { "node_modules/tailwind-merge": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
@@ -2528,6 +2776,23 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2545,6 +2810,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2681,6 +2956,113 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -2696,6 +3078,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
},
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }
+7 -2
View File
@@ -11,16 +11,20 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.95.0", "@tanstack/react-router": "^1.95.0",
"@use-gesture/react": "^10.3.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"zod": "^3.24.1" "zod": "^3.24.1",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@@ -30,6 +34,7 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.7" "vite": "^6.0.7",
"vitest": "^4.1.10"
} }
} }
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Size } from '@/lib/geometry'
import { ObjectShape } from './ObjectShape'
import { useEditorStore } from './store'
import { useViewport } from './useViewport'
import type { EditorGarden, EditorObject } from './types'
const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6 // hide the grid when cells get this small (zoomed out)
/**
* The editor canvas: a full-size SVG with a single world <g> that pan/zoom/pinch
* transforms. Renders a fading 1 m grid, the garden boundary, and its objects.
Review

Doc says "fading 1 m grid" but grid only toggles with constant opacity (no fade)

maintainability · flagged by 1 model

  • web/src/editor/GardenCanvas.tsx:13 — doc comment says "a fading 1 m grid", but the grid just toggles on/off via showGrid (GRID_CM * viewport.scale >= GRID_MIN_CELL_PX, line 52) with constant strokeOpacity={0.18} (line 74). There is no fade. The comment will mislead maintainers. Either implement a fade (opacity as a function of cell px) or drop "fading". Verified.

🪰 Gadfly · advisory

⚪ **Doc says "fading 1 m grid" but grid only toggles with constant opacity (no fade)** _maintainability · flagged by 1 model_ - **`web/src/editor/GardenCanvas.tsx:13`** — doc comment says "a fading 1 m grid", but the grid just toggles on/off via `showGrid` (`GRID_CM * viewport.scale >= GRID_MIN_CELL_PX`, line 52) with constant `strokeOpacity={0.18}` (line 74). There is no fade. The comment will mislead maintainers. Either implement a fade (opacity as a function of cell px) or drop "fading". Verified. <sub>🪰 Gadfly · advisory</sub>
* Interactions beyond viewport + basic select (move/resize/rotate, palette) land
* in #11; this is the foundation, driven by mock data until then.
*/
export function GardenCanvas({ garden, objects }: { garden: EditorGarden; objects: EditorObject[] }) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
const fittedRef = useRef(false)
Review

🟠 fittedRef prevents re-fit when garden prop changes

maintainability · flagged by 1 model

  • web/src/editor/GardenCanvas.tsx:21,45-50fittedRef gates the auto-fit effect so it only fires once per component lifetime. If the garden prop changes (which #11 will support), the canvas silently stays framed for the old garden. Track the last fitted garden ID and reset the flag when garden.id changes, or drive fit state from a stable key.

🪰 Gadfly · advisory

🟠 **fittedRef prevents re-fit when garden prop changes** _maintainability · flagged by 1 model_ - `web/src/editor/GardenCanvas.tsx:21,45-50` — `fittedRef` gates the auto-fit effect so it only fires once per component lifetime. If the `garden` prop changes (which #11 will support), the canvas silently stays framed for the old garden. Track the last fitted garden ID and reset the flag when `garden.id` changes, or drive fit state from a stable key. <sub>🪰 Gadfly · advisory</sub>
const viewport = useEditorStore((s) => s.viewport)
const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select)
const { fitToRect } = useViewport(svgRef)
const gardenRect = useMemo(
() => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }),
[garden.widthCm, garden.heightCm],
)
// Track the container's pixel size for fit math.
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(([entry]) => {
setSize({ w: entry.contentRect.width, h: entry.contentRect.height })
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// Frame the garden the first time we know the canvas size.
useEffect(() => {
if (!fittedRef.current && size.w > 0 && size.h > 0) {
Review

🟠 Auto-fit effect lacks garden dimension validation, allowing NaN viewport

error-handling · flagged by 1 model

  • web/src/editor/store.ts:24setViewport accepts any Viewport without clamping scale to [MIN_SCALE, MAX_SCALE]. A direct call with scale: 0 (or NaN/negative) corrupts the canvas permanently because downstream screenToWorld divides by vp.scale, yielding Infinity/NaN. The store should enforce the invariant it exports, either by clamping inside setViewport or by rejecting non-finite values. - web/src/editor/useViewport.ts:33-34toLocal silently falls back to…

🪰 Gadfly · advisory

🟠 **Auto-fit effect lacks garden dimension validation, allowing NaN viewport** _error-handling · flagged by 1 model_ - **`web/src/editor/store.ts:24`** — `setViewport` accepts any `Viewport` without clamping `scale` to `[MIN_SCALE, MAX_SCALE]`. A direct call with `scale: 0` (or `NaN`/negative) corrupts the canvas permanently because downstream `screenToWorld` divides by `vp.scale`, yielding `Infinity`/`NaN`. The store should enforce the invariant it exports, either by clamping inside `setViewport` or by rejecting non-finite values. - **`web/src/editor/useViewport.ts:33-34`** — `toLocal` silently falls back to… <sub>🪰 Gadfly · advisory</sub>
fittedRef.current = true
fitToRect(gardenRect, size)
}
}, [size, gardenRect, fitToRect])
const showGrid = GRID_CM * viewport.scale >= GRID_MIN_CELL_PX
const ordered = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
<svg
ref={svgRef}
className="h-full w-full select-none"
style={{ touchAction: 'none' }}
// Any pointerdown reaching the svg is empty space (objects stopPropagation),
// so it deselects.
onPointerDown={() => select(null)}
>
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{showGrid && (
<>
<defs>
<pattern id="garden-grid" width={GRID_CM} height={GRID_CM} patternUnits="userSpaceOnUse">
Review

🟠 Hardcoded SVG pattern ID risks collision across canvas instances

error-handling, maintainability · flagged by 2 models

  • web/src/editor/GardenCanvas.tsx:69 — Hardcoded SVG <pattern id="garden-grid"> will collide if multiple GardenCanvas instances ever render on the same page (e.g. side-by-side comparisons, future features, tests). Use React's useId() to generate a unique pattern ID per instance and reference it with fill={url(#${gridId})}.

🪰 Gadfly · advisory

🟠 **Hardcoded SVG pattern ID risks collision across canvas instances** _error-handling, maintainability · flagged by 2 models_ - **`web/src/editor/GardenCanvas.tsx:69`** — Hardcoded SVG `<pattern id="garden-grid">` will collide if multiple `GardenCanvas` instances ever render on the same page (e.g. side-by-side comparisons, future features, tests). Use React's `useId()` to generate a unique pattern ID per instance and reference it with `fill={`url(#${gridId})`}`. <sub>🪰 Gadfly · advisory</sub>
<path
d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`}
fill="none"
stroke="#808080"
strokeOpacity={0.18}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
</pattern>
</defs>
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill="url(#garden-grid)" />
</>
)}
{/* Garden boundary. */}
<rect
x={0}
y={0}
width={garden.widthCm}
height={garden.heightCm}
fill="none"
stroke="#3f8f4f"
strokeOpacity={0.7}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
{ordered.map((o) => (
<ObjectShape key={o.id} object={o} selected={o.id === selectedId} onSelect={select} />
))}
</g>
</svg>
{/* Overlay: zoom readout + fit control. */}
<div className="pointer-events-none absolute left-3 top-3 rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur">
{viewport.scale.toFixed(2)} px/cm
</div>
<div className="absolute bottom-3 right-3">
<button
type="button"
onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
className="rounded-md border border-border bg-surface/90 px-3 py-1.5 text-sm font-medium text-fg shadow-sm outline-none backdrop-blur transition-colors hover:bg-border/50 focus-visible:ring-2 focus-visible:ring-accent/40"
Review

🟡 Fit button duplicates Button/buttonClasses styling instead of reusing the shared component

maintainability · flagged by 1 model

  • web/src/editor/GardenCanvas.tsx:111 — The "Fit" button hand-rolls its own Tailwind class string instead of reusing the shared Button/buttonClasses helper (web/src/components/ui/Button.tsx:7-17), which already defines this exact "ghost" styling. The rest of the app follows the shared-button convention (GardensPage.tsx uses <Button>). This new component is the one place that breaks that pattern, so a future button-style tweak would need updating in two places. Fix: `<button className…

🪰 Gadfly · advisory

🟡 **Fit button duplicates Button/buttonClasses styling instead of reusing the shared component** _maintainability · flagged by 1 model_ - `web/src/editor/GardenCanvas.tsx:111` — The "Fit" button hand-rolls its own Tailwind class string instead of reusing the shared `Button`/`buttonClasses` helper (`web/src/components/ui/Button.tsx:7-17`), which already defines this exact "ghost" styling. The rest of the app follows the shared-button convention (`GardensPage.tsx` uses `<Button>`). This new component is the one place that breaks that pattern, so a future button-style tweak would need updating in two places. Fix: `<button className… <sub>🪰 Gadfly · advisory</sub>
>
Fit
</button>
</div>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
import type { PointerEvent } from 'react'
import type { EditorObject } from './types'
// Default fills by kind (overridable per object via object.color). Muted, earthy
// tones so plops (added in #15) read clearly on top.
const kindColors: Record<string, string> = {
Review

Duplicated #8a8a8a default fill (kindColors entry + fillFor fallback)

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **Duplicated #8a8a8a default fill (kindColors entry + fillFor fallback)** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
bed: '#8a6d4b',
grow_bag: '#9c7a52',
container: '#6b7f8a',
in_ground: '#7a6a4a',
tree: '#4f7a4f',
path: '#b8b0a0',
structure: '#8a8a8a',
}
function fillFor(o: EditorObject): string {
return o.color ?? kindColors[o.kind] ?? '#8a8a8a'
}
/**
* One garden object in world (cm) space: a centered rect or circle, rotated
* about its center, with the object's name. The parent world <g> applies the
* viewport scale, so geometry is authored in cm; strokes use
* vector-effect=non-scaling-stroke to stay a constant pixel width at any zoom.
* Full move/resize/rotate interactions come in #11.
*/
export function ObjectShape({
Review

🟡 ObjectShape lacks memoization, causing unnecessary re-render of every object on each pan/zoom/pinch/fit-animation frame

performance · flagged by 2 models

  • web/src/editor/GardenCanvas.tsx:97-99 / web/src/editor/ObjectShape.tsx:27ObjectShape isn't memoized, so every object in the scene re-renders on every pan/wheel/pinch tick and on every frame of the fitToRect animation tween. GardenCanvas subscribes to viewport from the zustand store (GardenCanvas.tsx:23), and useViewport's onDrag/onWheel/onPinch handlers call setViewport on essentially every gesture event (useViewport.ts:42-58), while fitToRect's rAF loop calls it…

🪰 Gadfly · advisory

🟡 **ObjectShape lacks memoization, causing unnecessary re-render of every object on each pan/zoom/pinch/fit-animation frame** _performance · flagged by 2 models_ - `web/src/editor/GardenCanvas.tsx:97-99` / `web/src/editor/ObjectShape.tsx:27` — `ObjectShape` isn't memoized, so every object in the scene re-renders on every pan/wheel/pinch tick and on every frame of the `fitToRect` animation tween. `GardenCanvas` subscribes to `viewport` from the zustand store (`GardenCanvas.tsx:23`), and `useViewport`'s `onDrag`/`onWheel`/`onPinch` handlers call `setViewport` on essentially every gesture event (`useViewport.ts:42-58`), while `fitToRect`'s rAF loop calls it… <sub>🪰 Gadfly · advisory</sub>
object,
selected,
onSelect,
}: {
object: EditorObject
selected: boolean
onSelect: (id: number) => void
}) {
const fill = fillFor(object)
const halfW = object.widthCm / 2
Outdated
Review

🟠 ObjectShape renders invalid SVG for zero or negative dimensions

error-handling, maintainability · flagged by 3 models

  • web/src/editor/store.ts:24setViewport accepts any Viewport without clamping scale to [MIN_SCALE, MAX_SCALE]. A direct call with scale: 0 (or NaN/negative) corrupts the canvas permanently because downstream screenToWorld divides by vp.scale, yielding Infinity/NaN. The store should enforce the invariant it exports, either by clamping inside setViewport or by rejecting non-finite values. - web/src/editor/useViewport.ts:33-34toLocal silently falls back to…

🪰 Gadfly · advisory

🟠 **ObjectShape renders invalid SVG for zero or negative dimensions** _error-handling, maintainability · flagged by 3 models_ - **`web/src/editor/store.ts:24`** — `setViewport` accepts any `Viewport` without clamping `scale` to `[MIN_SCALE, MAX_SCALE]`. A direct call with `scale: 0` (or `NaN`/negative) corrupts the canvas permanently because downstream `screenToWorld` divides by `vp.scale`, yielding `Infinity`/`NaN`. The store should enforce the invariant it exports, either by clamping inside `setViewport` or by rejecting non-finite values. - **`web/src/editor/useViewport.ts:33-34`** — `toLocal` silently falls back to… <sub>🪰 Gadfly · advisory</sub>
const halfH = object.heightCm / 2
// Label size scales with the object but is clamped to a sensible cm range.
const fontCm = Math.max(8, Math.min(40, Math.min(object.widthCm, object.heightCm) * 0.28))
function handleDown(e: PointerEvent) {
e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect
onSelect(object.id)
}
return (
<g
transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}
onPointerDown={handleDown}
style={{ cursor: 'pointer' }}
>
{object.shape === 'circle' ? (
<circle
cx={0}
cy={0}
r={halfW}
fill={fill}
fillOpacity={0.85}
stroke={selected ? '#2f7a3e' : '#00000033'}
Outdated
Review

🟠 Circle shapes render radius from widthCm only, silently ignoring heightCm even though the backend allows them to differ

correctness · flagged by 1 model

  • web/src/editor/ObjectShape.tsx:60 — circle rendering uses only halfW (r={halfW}) for the radius and silently ignores heightCm. Cross-checked internal/service/objects.go:223-256 (finalizeObject): it validates WidthCM and HeightCM independently via validDimensionCM with no constraint that they be equal for ShapeCircle objects. So a backend-valid object with shape=circle, widthCm=100, heightCm=140 would render as a perfect circle of radius 50, silently discarding the height.…

🪰 Gadfly · advisory

🟠 **Circle shapes render radius from widthCm only, silently ignoring heightCm even though the backend allows them to differ** _correctness · flagged by 1 model_ - `web/src/editor/ObjectShape.tsx:60` — circle rendering uses only `halfW` (`r={halfW}`) for the radius and silently ignores `heightCm`. Cross-checked `internal/service/objects.go:223-256` (`finalizeObject`): it validates `WidthCM` and `HeightCM` independently via `validDimensionCM` with no constraint that they be equal for `ShapeCircle` objects. So a backend-valid object with `shape=circle, widthCm=100, heightCm=140` would render as a perfect circle of radius 50, silently discarding the height.… <sub>🪰 Gadfly · advisory</sub>
strokeWidth={selected ? 2 : 1}
vectorEffect="non-scaling-stroke"
/>
) : (
<rect
x={-halfW}
y={-halfH}
width={object.widthCm}
height={object.heightCm}
rx={Math.min(halfW, halfH) * 0.06}
Review

Uncommented magic factors 0.06 (rx) and 0.28 (font) lack rationale

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **Uncommented magic factors 0.06 (rx) and 0.28 (font) lack rationale** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
fill={fill}
fillOpacity={0.85}
stroke={selected ? '#2f7a3e' : '#00000033'}
strokeWidth={selected ? 2 : 1}
vectorEffect="non-scaling-stroke"
/>
)}
{object.name && (
<text
x={0}
y={0}
fontSize={fontCm}
textAnchor="middle"
dominantBaseline="central"
fill="#ffffff"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{object.name}
</text>
)}
</g>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { create } from 'zustand'
import type { Viewport } from '@/lib/geometry'
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
// selection, and which object is "focused" (zoomed into for plops, #15). All
// server state stays in react-query. Selection interactions grow in #11.
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
export const MAX_SCALE = 20 // px per cm — fully zoomed in
interface EditorState {
viewport: Viewport
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
selectedId: number | null
select: (id: number | null) => void
focusedObjectId: number | null
Review

🟡 focusedObjectId/setFocusedObject declared but unused (dead state until #15)

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **focusedObjectId/setFocusedObject declared but unused (dead state until #15)** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
setFocusedObject: (id: number | null) => void
}
export const useEditorStore = create<EditorState>((set) => ({
viewport: { tx: 0, ty: 0, scale: 1 },
setViewport: (next) =>
Review

🟠 setViewport accepts unclamped scale, allowing division-by-zero corruption

error-handling · flagged by 1 model

  • web/src/editor/store.ts:24setViewport accepts any Viewport without clamping scale to [MIN_SCALE, MAX_SCALE]. A direct call with scale: 0 (or NaN/negative) corrupts the canvas permanently because downstream screenToWorld divides by vp.scale, yielding Infinity/NaN. The store should enforce the invariant it exports, either by clamping inside setViewport or by rejecting non-finite values. - web/src/editor/useViewport.ts:33-34toLocal silently falls back to…

🪰 Gadfly · advisory

🟠 **setViewport accepts unclamped scale, allowing division-by-zero corruption** _error-handling · flagged by 1 model_ - **`web/src/editor/store.ts:24`** — `setViewport` accepts any `Viewport` without clamping `scale` to `[MIN_SCALE, MAX_SCALE]`. A direct call with `scale: 0` (or `NaN`/negative) corrupts the canvas permanently because downstream `screenToWorld` divides by `vp.scale`, yielding `Infinity`/`NaN`. The store should enforce the invariant it exports, either by clamping inside `setViewport` or by rejecting non-finite values. - **`web/src/editor/useViewport.ts:33-34`** — `toLocal` silently falls back to… <sub>🪰 Gadfly · advisory</sub>
set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
selectedId: null,
select: (id) => set({ selectedId: id }),
focusedObjectId: null,
setFocusedObject: (id) => set({ focusedObjectId: id }),
}))
+28
View File
@@ -0,0 +1,28 @@
// The object shape the canvas renders. A subset of the server's garden object
// (#10); #11 maps /full's objects onto this. Positioned by center in garden cm.
export type ObjectShapeKind = 'rect' | 'circle'
export interface EditorObject {
id: number
kind: string
name: string
shape: ObjectShapeKind
xCm: number
yCm: number
widthCm: number
heightCm: number
rotationDeg: number
zIndex: number
color?: string | null
plantable: boolean
Review

🟡 EditorObject.plantable is carried in mock data but never read by the canvas

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **EditorObject.plantable is carried in mock data but never read by the canvas** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
}
/** The garden being edited: its bounds define the canvas extent. */
export interface EditorGarden {
id: number
name: string
widthCm: number
heightCm: number
unitPref: 'metric' | 'imperial'
Review

EditorGarden.unitPref re-declares the UnitPref union instead of importing it from lib/units.ts

maintainability · flagged by 1 model

  • web/src/editor/types.ts:27EditorGarden.unitPref is typed as the inline literal 'metric' | 'imperial' instead of reusing the existing UnitPref type exported from web/src/lib/units.ts:5, which web/src/lib/gardens.ts:7,40 already imports for the same purpose. Duplicated source of truth for a domain-relevant union. Fix: import type { UnitPref } from '@/lib/units' and use it in place of the inline literal.

🪰 Gadfly · advisory

⚪ **EditorGarden.unitPref re-declares the UnitPref union instead of importing it from lib/units.ts** _maintainability · flagged by 1 model_ - `web/src/editor/types.ts:27` — `EditorGarden.unitPref` is typed as the inline literal `'metric' | 'imperial'` instead of reusing the existing `UnitPref` type exported from `web/src/lib/units.ts:5`, which `web/src/lib/gardens.ts:7,40` already imports for the same purpose. Duplicated source of truth for a domain-relevant union. Fix: `import type { UnitPref } from '@/lib/units'` and use it in place of the inline literal. <sub>🪰 Gadfly · advisory</sub>
}
+93
View File
@@ -0,0 +1,93 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import { useGesture } from '@use-gesture/react'
import { zoomToFitRect, zoomViewportAt, type Point, type Rect, type Size } from '@/lib/geometry'
import { MAX_SCALE, MIN_SCALE, useEditorStore } from './store'
const WHEEL_SENSITIVITY = 0.0015 // exponential zoom per wheel delta unit
const FIT_PADDING = 32 // px margin around a fitted rect
const FIT_DURATION_MS = 350
const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
Outdated
Review

🟡 Generic easing/lerp utilities inlined in hook instead of shared geometry lib

maintainability · flagged by 1 model

  • web/src/editor/useViewport.ts:10-11easeInOutCubic and lerp are generic, reusable math utilities defined inline in a gesture hook. They should live in geometry.ts (or a shared utils module) so they're discoverable, reusable, and testable alongside the other geometry helpers.

🪰 Gadfly · advisory

🟡 **Generic easing/lerp utilities inlined in hook instead of shared geometry lib** _maintainability · flagged by 1 model_ - **`web/src/editor/useViewport.ts:10-11`** — `easeInOutCubic` and `lerp` are generic, reusable math utilities defined inline in a gesture hook. They should live in `geometry.ts` (or a shared utils module) so they're discoverable, reusable, and testable alongside the other geometry helpers. <sub>🪰 Gadfly · advisory</sub>
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
/**
* Wires pan/zoom/pinch gestures onto the svg (via @use-gesture) and returns an
* animated `fitToRect`. Wheel and pinch zoom toward the pointer; drag on empty
* space pans (object dragging in #11 stops propagation). Scale is clamped to
* [MIN_SCALE, MAX_SCALE]. The svg must set touch-action:none so the browser
* doesn't hijack the gestures.
*/
export function useViewport(svgRef: RefObject<SVGSVGElement | null>) {
Review

Hook named useViewport returns only fitToRect, not viewport state — misleading name

maintainability · flagged by 1 model

  • web/src/editor/useViewport.ts:20 — the hook is named useViewport but returns only { fitToRect } (line 92); the viewport state itself lives in the zustand store (useEditorStore). A reader expecting useViewport() to surface viewport state will be surprised. Consider useViewportGestures or have it also return the viewport. Minor naming smell.

🪰 Gadfly · advisory

⚪ **Hook named `useViewport` returns only `fitToRect`, not viewport state — misleading name** _maintainability · flagged by 1 model_ - **`web/src/editor/useViewport.ts:20`** — the hook is named `useViewport` but returns only `{ fitToRect }` (line 92); the viewport state itself lives in the zustand store (`useEditorStore`). A reader expecting `useViewport()` to surface viewport state will be surprised. Consider `useViewportGestures` or have it also return the viewport. Minor naming smell. <sub>🪰 Gadfly · advisory</sub>
const setViewport = useEditorStore((s) => s.setViewport)
const animRef = useRef<number | null>(null)
const cancelAnim = useCallback(() => {
if (animRef.current != null) {
cancelAnimationFrame(animRef.current)
animRef.current = null
}
}, [])
const toLocal = useCallback(
Review

🟠 getBoundingClientRect forces synchronous layout on every pinch/wheel frame

error-handling, maintainability, performance · flagged by 2 models

  • Forced synchronous layout in gesture hot pathweb/src/editor/useViewport.ts:33 The toLocal helper calls svgRef.current?.getBoundingClientRect() inside every onWheel and onPinch event. During a pinch gesture this fires on every touch-move frame, forcing the browser to synchronously recalculate layout (reflow) while handling input. On lower-end devices this produces jank. Fix: cache the SVG bounding offset (e.g. in a ref updated on mount/resize via ResizeObserver or the exi…

🪰 Gadfly · advisory

🟠 **getBoundingClientRect forces synchronous layout on every pinch/wheel frame** _error-handling, maintainability, performance · flagged by 2 models_ * **Forced synchronous layout in gesture hot path** — `web/src/editor/useViewport.ts:33` The `toLocal` helper calls `svgRef.current?.getBoundingClientRect()` inside every `onWheel` and `onPinch` event. During a pinch gesture this fires on every touch-move frame, forcing the browser to synchronously recalculate layout (reflow) while handling input. On lower-end devices this produces jank. **Fix:** cache the SVG bounding offset (e.g. in a ref updated on mount/resize via `ResizeObserver` or the exi… <sub>🪰 Gadfly · advisory</sub>
(clientX: number, clientY: number): Point => {
const rect = svgRef.current?.getBoundingClientRect()
return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY }
},
[svgRef],
)
useGesture(
{
onDragStart: cancelAnim,
onDrag: ({ delta: [dx, dy], pinching, cancel }) => {
if (pinching) {
cancel()
return
}
setViewport((vp) => ({ ...vp, tx: vp.tx + dx, ty: vp.ty + dy }))
},
onWheelStart: cancelAnim,
onWheel: ({ event, delta: [, dy] }) => {
event.preventDefault()
const p = toLocal(event.clientX, event.clientY)
setViewport((vp) => zoomViewportAt(vp, p, vp.scale * Math.exp(-dy * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE))
Review

🟠 onWheel does not guard against NaN dy, permanently corrupting viewport

error-handling · flagged by 1 model

  • web/src/editor/store.ts:24setViewport accepts any Viewport without clamping scale to [MIN_SCALE, MAX_SCALE]. A direct call with scale: 0 (or NaN/negative) corrupts the canvas permanently because downstream screenToWorld divides by vp.scale, yielding Infinity/NaN. The store should enforce the invariant it exports, either by clamping inside setViewport or by rejecting non-finite values. - web/src/editor/useViewport.ts:33-34toLocal silently falls back to…

🪰 Gadfly · advisory

🟠 **onWheel does not guard against NaN dy, permanently corrupting viewport** _error-handling · flagged by 1 model_ - **`web/src/editor/store.ts:24`** — `setViewport` accepts any `Viewport` without clamping `scale` to `[MIN_SCALE, MAX_SCALE]`. A direct call with `scale: 0` (or `NaN`/negative) corrupts the canvas permanently because downstream `screenToWorld` divides by `vp.scale`, yielding `Infinity`/`NaN`. The store should enforce the invariant it exports, either by clamping inside `setViewport` or by rejecting non-finite values. - **`web/src/editor/useViewport.ts:33-34`** — `toLocal` silently falls back to… <sub>🪰 Gadfly · advisory</sub>
},
onPinchStart: cancelAnim,
onPinch: ({ origin: [ox, oy], offset: [scale] }) => {
const p = toLocal(ox, oy)
setViewport((vp) => zoomViewportAt(vp, p, scale, MIN_SCALE, MAX_SCALE))
},
},
{
target: svgRef,
eventOptions: { passive: false }, // so onWheel can preventDefault
drag: { filterTaps: true },
pinch: {
// Seed the pinch offset with the current scale so offset[0] is absolute.
from: () => [useEditorStore.getState().viewport.scale, 0],
scaleBounds: { min: MIN_SCALE, max: MAX_SCALE },
},
},
)
const fitToRect = useCallback(
(rect: Rect, viewport: Size) => {
Review

🟡 Parameter named viewport with type Size confuses with Viewport interface

maintainability · flagged by 3 models

  • web/src/editor/useViewport.ts:74 / web/src/lib/geometry.ts:97 — The fitToRect callback and zoomToFitRect both name their second parameter viewport even though its type is Size (pixel dimensions of the container). This shadows the Viewport transform interface and is confusing when reading call sites. Rename to containerSize or viewSize.

🪰 Gadfly · advisory

🟡 **Parameter named viewport with type Size confuses with Viewport interface** _maintainability · flagged by 3 models_ - **`web/src/editor/useViewport.ts:74`** / **`web/src/lib/geometry.ts:97`** — The `fitToRect` callback and `zoomToFitRect` both name their second parameter `viewport` even though its type is `Size` (pixel dimensions of the container). This shadows the `Viewport` transform interface and is confusing when reading call sites. Rename to `containerSize` or `viewSize`. <sub>🪰 Gadfly · advisory</sub>
cancelAnim()
const from = useEditorStore.getState().viewport
const to = zoomToFitRect(rect, viewport, FIT_PADDING, MIN_SCALE, MAX_SCALE)
const start = performance.now()
const step = (now: number) => {
const t = Math.min(1, (now - start) / FIT_DURATION_MS)
const e = easeInOutCubic(t)
setViewport({ tx: lerp(from.tx, to.tx, e), ty: lerp(from.ty, to.ty, e), scale: lerp(from.scale, to.scale, e) })
animRef.current = t < 1 ? requestAnimationFrame(step) : null
}
animRef.current = requestAnimationFrame(step)
},
[cancelAnim, setViewport],
)
useEffect(() => cancelAnim, [cancelAnim]) // stop any tween on unmount
return { fitToRect }
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
clampScale,
localToWorld,
screenToWorld,
worldToLocal,
worldToScreen,
zoomToFitRect,
zoomViewportAt,
type Viewport,
} from './geometry'
const vp: Viewport = { tx: 100, ty: 50, scale: 2 }
describe('world/screen transforms', () => {
it('round-trips a point', () => {
const p = { x: 37.5, y: -12.25 }
const back = screenToWorld(worldToScreen(p, vp), vp)
expect(back.x).toBeCloseTo(p.x, 9)
expect(back.y).toBeCloseTo(p.y, 9)
})
it('applies scale then translate', () => {
expect(worldToScreen({ x: 10, y: 10 }, vp)).toEqual({ x: 120, y: 70 })
})
})
describe('object-local/world transforms', () => {
const center = { x: 500, y: 300 }
it('round-trips through any rotation', () => {
for (const deg of [0, 30, 90, 180, 270, 45, -60, 123.4]) {
const local = { x: 40, y: -25 }
const back = worldToLocal(localToWorld(local, center, deg), center, deg)
expect(back.x).toBeCloseTo(local.x, 6)
expect(back.y).toBeCloseTo(local.y, 6)
}
})
it('rotates 90° clockwise on screen (y down)', () => {
// A point 10cm to the object's right, rotated 90°, lands 10cm below center.
const w = localToWorld({ x: 10, y: 0 }, center, 90)
expect(w.x).toBeCloseTo(center.x, 6)
expect(w.y).toBeCloseTo(center.y + 10, 6)
})
it('local origin maps to the object center', () => {
expect(localToWorld({ x: 0, y: 0 }, center, 37)).toEqual(center)
})
})
describe('clampScale', () => {
it('bounds the value', () => {
expect(clampScale(0.001, 0.05, 20)).toBe(0.05)
expect(clampScale(100, 0.05, 20)).toBe(20)
expect(clampScale(3, 0.05, 20)).toBe(3)
})
})
describe('zoomViewportAt', () => {
it('keeps the point under the cursor stationary', () => {
const cursor = { x: 640, y: 360 }
const before = screenToWorld(cursor, vp)
const next = zoomViewportAt(vp, cursor, vp.scale * 1.25, 0.05, 20)
const after = screenToWorld(cursor, next)
expect(after.x).toBeCloseTo(before.x, 6)
expect(after.y).toBeCloseTo(before.y, 6)
expect(next.scale).toBeCloseTo(2.5, 9)
})
it('clamps the scale', () => {
expect(zoomViewportAt(vp, { x: 0, y: 0 }, 1000, 0.05, 20).scale).toBe(20)
})
})
describe('zoomToFitRect', () => {
it('centers the rect and fits it within padding', () => {
const viewport = { w: 800, h: 600 }
const rect = { x: 0, y: 0, w: 1000, h: 1000 }
const fit = zoomToFitRect(rect, viewport, 20, 0.05, 20)
// Limiting dimension is height: (600-40)/1000 = 0.56.
expect(fit.scale).toBeCloseTo(0.56, 9)
// The rect's center (500,500) should map to the viewport center (400,300).
const c = worldToScreen({ x: 500, y: 500 }, fit)
expect(c.x).toBeCloseTo(400, 6)
expect(c.y).toBeCloseTo(300, 6)
})
it('respects the min/max scale clamp', () => {
const fit = zoomToFitRect({ x: 0, y: 0, w: 1, h: 1 }, { w: 800, h: 600 }, 0, 0.05, 20)
expect(fit.scale).toBe(20)
})
})
+117
View File
@@ -0,0 +1,117 @@
// Pure geometry for the editor. Everything is centimeters in "world" space
// (garden origin top-left, x→right, y→down) except the viewport transform, which
// maps world → screen pixels. Kept dependency-free and unit-tested; the canvas
// and gesture code build on top.
export interface Point {
x: number
y: number
}
export interface Size {
w: number
h: number
}
/** Axis-aligned rectangle by top-left corner + size, in world cm. */
export interface Rect {
x: number
y: number
w: number
h: number
}
/** The viewport: world→screen is `screen = world * scale + t`. scale is px/cm. */
export interface Viewport {
tx: number
ty: number
scale: number
}
export function worldToScreen(p: Point, vp: Viewport): Point {
return { x: p.x * vp.scale + vp.tx, y: p.y * vp.scale + vp.ty }
}
export function screenToWorld(p: Point, vp: Viewport): Point {
return { x: (p.x - vp.tx) / vp.scale, y: (p.y - vp.ty) / vp.scale }
Review

🟠 screenToWorld divides by scale without zero guard, and store allows arbitrary scale values

error-handling · flagged by 1 model

  • web/src/lib/geometry.ts:36screenToWorld divides by vp.scale without guarding against zero. The useEditorStore introduced in this PR accepts any Viewport via setViewport with no validation, so a caller (now or in a future PR) could set scale: 0, producing Infinity/NaN that propagates through transforms and breaks rendering. Fix: either add an assert(scale !== 0) or return a safe fallback, and consider clamping inside setViewport at the store level.

🪰 Gadfly · advisory

🟠 **screenToWorld divides by scale without zero guard, and store allows arbitrary scale values** _error-handling · flagged by 1 model_ - **`web/src/lib/geometry.ts:36`** — `screenToWorld` divides by `vp.scale` without guarding against zero. The `useEditorStore` introduced in this PR accepts any `Viewport` via `setViewport` with no validation, so a caller (now or in a future PR) could set `scale: 0`, producing `Infinity`/`NaN` that propagates through transforms and breaks rendering. **Fix:** either add an `assert(scale !== 0)` or return a safe fallback, and consider clamping inside `setViewport` at the store level. <sub>🪰 Gadfly · advisory</sub>
}
/**
* A point in an object's local frame (origin at the object's center, axes
* aligned to the unrotated object) → garden world coordinates. rotationDeg is
* clockwise (screen y grows downward, so a positive angle rotates clockwise on
* screen). Plops live in this local frame, so moving/rotating the parent object
* moves them for free.
*/
export function localToWorld(local: Point, center: Point, rotationDeg: number): Point {
const r = (rotationDeg * Math.PI) / 180
const cos = Math.cos(r)
const sin = Math.sin(r)
return {
x: center.x + local.x * cos - local.y * sin,
y: center.y + local.x * sin + local.y * cos,
}
}
/** Inverse of localToWorld: garden world point → the object's local frame. */
export function worldToLocal(world: Point, center: Point, rotationDeg: number): Point {
const r = (rotationDeg * Math.PI) / 180
const cos = Math.cos(r)
const sin = Math.sin(r)
const dx = world.x - center.x
const dy = world.y - center.y
return { x: dx * cos + dy * sin, y: -dx * sin + dy * cos }
}
/** Clamp a scale (px/cm) into [min, max]. */
export function clampScale(scale: number, min: number, max: number): number {
return Math.min(max, Math.max(min, scale))
}
/**
* Re-scale the viewport while keeping the world point currently under
* `screenPoint` stationary — the math behind wheel-zoom-to-cursor and pinch. The
* new scale is clamped to [minScale, maxScale].
*/
export function zoomViewportAt(
vp: Viewport,
screenPoint: Point,
nextScale: number,
minScale: number,
maxScale: number,
): Viewport {
const scale = clampScale(nextScale, minScale, maxScale)
const world = screenToWorld(screenPoint, vp)
return {
scale,
tx: screenPoint.x - world.x * scale,
ty: screenPoint.y - world.y * scale,
}
}
/**
* The viewport that frames `rect` centered in a viewport of `viewport` pixels,
* with `padding` px of margin on every side. Scale is clamped to [minScale,
* maxScale] so a tiny/huge rect still lands in a sane zoom.
*/
export function zoomToFitRect(
Review

🟡 Parameter named viewport with type Size confuses with Viewport interface

maintainability · flagged by 1 model

  • web/src/editor/useViewport.ts:74 / web/src/lib/geometry.ts:97 — The fitToRect callback and zoomToFitRect both name their second parameter viewport even though its type is Size (pixel dimensions of the container). This shadows the Viewport transform interface and is confusing when reading call sites. Rename to containerSize or viewSize.

🪰 Gadfly · advisory

🟡 **Parameter named viewport with type Size confuses with Viewport interface** _maintainability · flagged by 1 model_ - **`web/src/editor/useViewport.ts:74`** / **`web/src/lib/geometry.ts:97`** — The `fitToRect` callback and `zoomToFitRect` both name their second parameter `viewport` even though its type is `Size` (pixel dimensions of the container). This shadows the `Viewport` transform interface and is confusing when reading call sites. Rename to `containerSize` or `viewSize`. <sub>🪰 Gadfly · advisory</sub>
rect: Rect,
viewport: Size,
padding: number,
minScale: number,
maxScale: number,
): Viewport {
const availW = Math.max(1, viewport.w - padding * 2)
Review

🟠 zoomToFitRect mishandles negative rect dimensions, mis-centering the viewport

error-handling · flagged by 1 model

  • web/src/editor/store.ts:24setViewport accepts any Viewport without clamping scale to [MIN_SCALE, MAX_SCALE]. A direct call with scale: 0 (or NaN/negative) corrupts the canvas permanently because downstream screenToWorld divides by vp.scale, yielding Infinity/NaN. The store should enforce the invariant it exports, either by clamping inside setViewport or by rejecting non-finite values. - web/src/editor/useViewport.ts:33-34toLocal silently falls back to…

🪰 Gadfly · advisory

🟠 **zoomToFitRect mishandles negative rect dimensions, mis-centering the viewport** _error-handling · flagged by 1 model_ - **`web/src/editor/store.ts:24`** — `setViewport` accepts any `Viewport` without clamping `scale` to `[MIN_SCALE, MAX_SCALE]`. A direct call with `scale: 0` (or `NaN`/negative) corrupts the canvas permanently because downstream `screenToWorld` divides by `vp.scale`, yielding `Infinity`/`NaN`. The store should enforce the invariant it exports, either by clamping inside `setViewport` or by rejecting non-finite values. - **`web/src/editor/useViewport.ts:33-34`** — `toLocal` silently falls back to… <sub>🪰 Gadfly · advisory</sub>
const availH = Math.max(1, viewport.h - padding * 2)
const rectW = Math.max(rect.w, 1e-6)
const rectH = Math.max(rect.h, 1e-6)
const scale = clampScale(Math.min(availW / rectW, availH / rectH), minScale, maxScale)
const centerX = rect.x + rect.w / 2
const centerY = rect.y + rect.h / 2
return {
scale,
tx: viewport.w / 2 - centerX * scale,
ty: viewport.h / 2 - centerY * scale,
}
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { cmFromFtIn, cmFromMeters, displayFromCm, formatCm, formatDimensions } from './units'
describe('cm conversions', () => {
it('feet+inches → whole cm', () => {
expect(cmFromFtIn(4)).toBe(122) // 4ft = 121.92 → 122
expect(cmFromFtIn(8)).toBe(244)
expect(cmFromFtIn(4, 6)).toBe(137) // 4'6" = 137.16 → 137
})
it('meters → whole cm', () => {
expect(cmFromMeters(10)).toBe(1000)
expect(cmFromMeters(1.22)).toBe(122)
})
})
describe('displayFromCm', () => {
it('metric keeps cm precision', () => {
expect(displayFromCm(1000, 'metric')).toBe(10)
expect(displayFromCm(122, 'metric')).toBe(1.22)
})
it('imperial rounds to 0.1 ft so integers stay clean', () => {
expect(displayFromCm(122, 'imperial')).toBe(4) // not 4.0026
expect(displayFromCm(244, 'imperial')).toBe(8) // not 8.01
})
})
describe('formatCm', () => {
it('metric shows meters', () => {
expect(formatCm(122, 'metric')).toBe('1.22 m')
expect(formatCm(1000, 'metric')).toBe('10 m')
})
it('imperial shows feet and inches, carrying 12″ up', () => {
expect(formatCm(122, 'imperial')).toBe('4 0″')
expect(formatCm(244, 'imperial')).toBe('8 0″')
expect(formatCm(137, 'imperial')).toBe('4 6″')
})
it('formats width × height', () => {
expect(formatDimensions(122, 244, 'imperial')).toBe('4 0″ × 8 0″')
})
})
+25 -7
View File
@@ -1,13 +1,31 @@
import { getRouteApi } from '@tanstack/react-router' import { GardenCanvas } from '@/editor/GardenCanvas'
import { PageStub } from '@/components/PageStub' import type { EditorGarden, EditorObject } from '@/editor/types'
const routeApi = getRouteApi('/gardens/$gardenId') // Mock scene for the canvas foundation (#9). #11 replaces this with the garden's
// real /full payload keyed on the route's gardenId.
const mockGarden: EditorGarden = { id: 0, name: 'Demo garden', widthCm: 1200, heightCm: 800, unitPref: 'metric' }
const mockObjects: EditorObject[] = [
{ id: 1, kind: 'bed', name: 'Bed A', shape: 'rect', xCm: 300, yCm: 250, widthCm: 400, heightCm: 120, rotationDeg: 0, zIndex: 1, plantable: true },
{ id: 2, kind: 'bed', name: 'Bed B', shape: 'rect', xCm: 300, yCm: 450, widthCm: 400, heightCm: 120, rotationDeg: 0, zIndex: 1, plantable: true },
{ id: 3, kind: 'container', name: 'Pot', shape: 'circle', xCm: 800, yCm: 250, widthCm: 120, heightCm: 120, rotationDeg: 0, zIndex: 2, plantable: true },
{ id: 4, kind: 'path', name: '', shape: 'rect', xCm: 600, yCm: 400, widthCm: 60, heightCm: 720, rotationDeg: 0, zIndex: 0, plantable: false },
{ id: 5, kind: 'tree', name: 'Apple', shape: 'circle', xCm: 980, yCm: 620, widthCm: 200, heightCm: 200, rotationDeg: 0, zIndex: 2, plantable: false },
{ id: 6, kind: 'bed', name: 'Angled', shape: 'rect', xCm: 920, yCm: 480, widthCm: 260, heightCm: 100, rotationDeg: 30, zIndex: 1, plantable: true },
]
export function GardenEditorPage() { export function GardenEditorPage() {
const { gardenId } = routeApi.useParams()
return ( return (
<PageStub title={`Garden ${gardenId}`}> <div className="flex h-[calc(100vh-10rem)] flex-col gap-3">
The field editor (canvas, palette, plops) arrives across issues #9#15. <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
</PageStub> <h1 className="text-lg font-semibold tracking-tight">Editor preview</h1>
<span className="text-sm text-muted">
Mock garden drag to pan, wheel/pinch to zoom, tap an object to select. Real data + editing land in #11.
</span>
</div>
<div className="min-h-0 flex-1">
<GardenCanvas garden={mockGarden} objects={mockObjects} />
</div>
</div>
) )
} }