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 1109 additions and 13 deletions
+415 -4
View File
@@ -10,11 +10,13 @@
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.95.0",
"@use-gesture/react": "^10.3.1",
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1"
"zod": "^3.24.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
@@ -24,7 +26,8 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=20"
@@ -1200,6 +1203,13 @@
"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": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1638,6 +1648,24 @@
"@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": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1659,7 +1687,7 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1675,6 +1703,24 @@
"@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": {
"version": "4.7.0",
"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"
}
},
"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": {
"version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
@@ -1764,6 +1933,16 @@
],
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1790,7 +1969,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -1842,6 +2021,13 @@
"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": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1894,6 +2080,26 @@
"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": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2325,6 +2531,27 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2487,6 +2714,13 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2497,6 +2731,20 @@
"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": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
@@ -2528,6 +2776,23 @@
"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": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2545,6 +2810,16 @@
"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": {
"version": "5.9.3",
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -2696,6 +3078,35 @@
"funding": {
"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",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.95.0",
"@use-gesture/react": "^10.3.1",
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1"
"zod": "^3.24.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
@@ -30,6 +34,7 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"
"vite": "^6.0.7",
"vitest": "^4.1.10"
}
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/Button'
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 // grid is invisible below this cell size (zoomed out)
const GRID_MAX_OPACITY = 0.18
/**
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>
* The editor canvas: a full-size SVG with a single world <g> that pan/zoom/pinch
* transforms. Renders a 1 m grid that fades in as you zoom, the garden boundary,
* and its objects. Interactions beyond viewport + basic select (move/resize/
* rotate, palette) land in #11; this is the foundation, driven by mock data.
*/
export function GardenCanvas({ garden, objects }: { garden: EditorGarden; objects: EditorObject[] }) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
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 [size, setSize] = useState<Size>({ w: 0, h: 0 })
const fittedForRef = useRef<number | null>(null)
const gridId = 'garden-grid-' + useId().replace(/:/g, '')
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()
}, [])
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>
// Frame the garden once the canvas size is known, and again if we switch to a
// different garden.
useEffect(() => {
const usable = size.w > 0 && size.h > 0 && garden.widthCm > 0 && garden.heightCm > 0
if (usable && fittedForRef.current !== garden.id) {
fittedForRef.current = garden.id
fitToRect(gardenRect, size)
}
}, [size, garden.id, garden.widthCm, garden.heightCm, gardenRect, fitToRect])
// Grid fades in as cells grow from GRID_MIN_CELL_PX to 2× that.
const cellPx = GRID_CM * viewport.scale
const gridOpacity = Math.max(0, Math.min(GRID_MAX_OPACITY, ((cellPx - GRID_MIN_CELL_PX) / GRID_MIN_CELL_PX) * GRID_MAX_OPACITY))
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),
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>
// so it deselects.
onPointerDown={() => select(null)}
>
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{gridOpacity > 0.005 && (
<>
<defs>
<pattern id={gridId} width={GRID_CM} height={GRID_CM} patternUnits="userSpaceOnUse">
<path
d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`}
fill="none"
stroke="#808080"
strokeOpacity={gridOpacity}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
</pattern>
</defs>
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} />
</>
)}
{/* 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. */}
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>
<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>
<Button
variant="ghost"
onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
className="absolute bottom-3 right-3 bg-surface/90 py-1.5 shadow-sm backdrop-blur"
>
Fit
</Button>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { memo, type PointerEvent } from 'react'
import type { EditorObject } from './types'
const DEFAULT_FILL = '#8a8a8a'
// Default fills by kind (overridable per object via object.color). Muted, earthy
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>
// tones so plops (added in #15) read clearly on top.
const kindColors: Record<string, string> = {
bed: '#8a6d4b',
grow_bag: '#9c7a52',
container: '#6b7f8a',
in_ground: '#7a6a4a',
tree: '#4f7a4f',
path: '#b8b0a0',
structure: DEFAULT_FILL,
}
// Label font size = this fraction of the object's smaller side, clamped to a
// readable cm range. Corner radius = this fraction of the smaller half-side.
const LABEL_FONT_FACTOR = 0.28
const LABEL_FONT_MIN_CM = 8
const LABEL_FONT_MAX_CM = 40
const CORNER_RADIUS_FACTOR = 0.06
function fillFor(o: EditorObject): string {
return o.color ?? kindColors[o.kind] ?? DEFAULT_FILL
}
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>
/**
* One garden object in world (cm) space: a centered rect or ellipse (a circle
* when width == height), rotated about its center, with the object's name. The
* parent world <g> applies the viewport scale, so geometry is authored in cm and
* strokes use vector-effect=non-scaling-stroke to stay a constant pixel width at
* any zoom. memo'd so a pan/zoom (which only changes the world <g> transform)
* doesn't re-render every object. Full move/resize/rotate come in #11.
*/
export const ObjectShape = memo(function ObjectShape({
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>
object,
selected,
onSelect,
}: {
object: EditorObject
selected: boolean
onSelect: (id: number) => void
}) {
const fill = fillFor(object)
const halfW = Math.max(0, object.widthCm / 2)
const halfH = Math.max(0, object.heightCm / 2)
const fontCm = Math.max(
LABEL_FONT_MIN_CM,
Math.min(LABEL_FONT_MAX_CM, Math.min(object.widthCm, object.heightCm) * LABEL_FONT_FACTOR),
)
function handleDown(e: PointerEvent) {
e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect
onSelect(object.id)
}
const stroke = selected ? '#2f7a3e' : '#00000033'
const strokeWidth = selected ? 2 : 1
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>
return (
<g
transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}
onPointerDown={handleDown}
style={{ cursor: 'pointer' }}
>
{object.shape === 'circle' ? (
<ellipse
cx={0}
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>
cy={0}
rx={halfW}
ry={halfH}
fill={fill}
fillOpacity={0.85}
stroke={stroke}
strokeWidth={strokeWidth}
vectorEffect="non-scaling-stroke"
/>
) : (
<rect
x={-halfW}
y={-halfH}
width={halfW * 2}
height={halfH * 2}
rx={Math.min(halfW, halfH) * CORNER_RADIUS_FACTOR}
fill={fill}
fillOpacity={0.85}
stroke={stroke}
strokeWidth={strokeWidth}
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 }),
}))
+30
View File
@@ -0,0 +1,30 @@
// 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.
import type { UnitPref } from '@/lib/units'
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
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>
color?: string | null
plantable: boolean
}
/** The garden being edited: its bounds define the canvas extent. */
export interface EditorGarden {
id: number
name: string
widthCm: number
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>
heightCm: number
unitPref: UnitPref
}
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import { useGesture } from '@use-gesture/react'
import {
easeInOutCubic,
lerp,
zoomToFitRect,
zoomViewportAt,
type Point,
type Rect,
type Size,
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>
} 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
/**
* 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
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>
* 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>) {
const setViewport = useEditorStore((s) => s.setViewport)
const animRef = useRef<number | null>(null)
const cancelAnim = useCallback(() => {
if (animRef.current != null) {
cancelAnimationFrame(animRef.current)
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>
animRef.current = null
}
}, [])
// Client (page) coords → coords within the svg, for anchoring zoom.
const clientToCanvas = useCallback(
(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 }))
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>
},
onWheelStart: cancelAnim,
onWheel: ({ event, delta: [, dy] }) => {
event.preventDefault()
if (!Number.isFinite(dy)) return // never let a bad delta corrupt the viewport
const p = clientToCanvas(event.clientX, event.clientY)
setViewport((vp) => zoomViewportAt(vp, p, vp.scale * Math.exp(-dy * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE))
},
onPinchStart: cancelAnim,
onPinch: ({ origin: [ox, oy], offset: [scale] }) => {
if (!Number.isFinite(scale)) return
const p = clientToCanvas(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.
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>
from: () => [useEditorStore.getState().viewport.scale, 0],
scaleBounds: { min: MIN_SCALE, max: MAX_SCALE },
},
},
)
const fitToRect = useCallback(
(rect: Rect, canvasSize: Size) => {
cancelAnim()
const from = useEditorStore.getState().viewport
const to = zoomToFitRect(rect, canvasSize, 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)
})
})
+128
View File
@@ -0,0 +1,128 @@
// 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))
}
/** Linear interpolation. */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
/** Cubic ease-in-out for viewport tweens. */
export function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
}
/**
* 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,
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>
ty: screenPoint.y - world.y * scale,
}
}
/**
* The viewport that frames `rect` centered in a canvas of `canvasSize` pixels,
* with `padding` px of margin on every side. Scale is clamped to [minScale,
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>
* maxScale] so a tiny/huge rect still lands in a sane zoom. Rect dimensions are
* taken by magnitude, so a degenerate (0/negative) size can't corrupt the math.
*/
export function zoomToFitRect(
rect: Rect,
canvasSize: Size,
padding: number,
minScale: number,
maxScale: number,
): Viewport {
const availW = Math.max(1, canvasSize.w - padding * 2)
const availH = Math.max(1, canvasSize.h - padding * 2)
const rectW = Math.max(Math.abs(rect.w), 1e-6)
const rectH = Math.max(Math.abs(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: canvasSize.w / 2 - centerX * scale,
ty: canvasSize.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 { PageStub } from '@/components/PageStub'
import { GardenCanvas } from '@/editor/GardenCanvas'
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() {
const { gardenId } = routeApi.useParams()
return (
<PageStub title={`Garden ${gardenId}`}>
The field editor (canvas, palette, plops) arrives across issues #9#15.
</PageStub>
<div className="flex h-[calc(100vh-10rem)] flex-col gap-3">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<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>
)
}