Fix the real bugs surfaced by the PR #30 adversarial review (duplicates
across models collapsed):
- Optimistic PATCH now bumps the cached row version so a second edit
started before the first PATCH resolves sends the next version instead
of re-sending a stale one and self-conflicting (spurious 409 +
"updated elsewhere" toast + lost edit). Drop the per-PATCH onSettled
refetch: onSuccess already writes the authoritative row and onError
always rolls back, so the invalidate was a request storm during drags
and a refetch race, with no drift benefit.
- Color picker committed a PATCH on every onChange (continuous during a
native picker drag). Track the value locally and commit one PATCH on
blur.
- Rotate read shiftKey from the pointerdown event, so toggling Shift
mid-drag didn't switch snap/free. Pass the live pointermove event into
the gesture callback and read shiftKey from it.
- Window pointer listeners had no unmount cleanup: deleting/deselecting
an object mid-drag leaked listeners and could leave objectDragging
stuck true (freezing pan). Track the active gesture's cleanup and run
it on unmount.
- commitDim accepted 0/negative width/height (invalid geometry flashed
into the optimistic cache before rollback) and fired a spurious 1cm
PATCH on blur-without-edit from cm<->ft rounding drift. Guard
width/height against the server minimum and compare at display
precision first.
Perf:
- Split the z-order sort from the live-merge so a pointermove is an O(n)
map, not an O(n log n) re-sort.
- Memoize the toEditorObject map so object identities are stable across
renders and ObjectShape's memo holds.
- Snapshot the svg rect once per gesture instead of calling
getBoundingClientRect (a layout reflow) on every pointermove.
Maintainability:
- Move per-kind default z-index into kinds.ts (single source of truth).
- Rename the ToastItem `toast` param so it stops shadowing the module's
`toast` export.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Turns the canvas foundation into the real editor, loading a garden from the
server and editing it with optimistic sync. Completes Phase 3.
- lib/objects.ts: react-query over GET /gardens/:id/full + optimistic
create/update/delete. Update applies to the cache up front and PATCHes
with the row version; a 409 adopts the server's `current` row and toasts
"updated elsewhere". One PATCH per gesture.
- editor/store.ts: adds liveObject (in-gesture geometry, rendered instantly),
armedKind (palette tap-to-place), and objectDragging (so the pan gesture
stands down while an object drag owns the pointer).
- editor/Palette.tsx + kinds.ts: the seven kinds with icons + default sizes;
tap a kind, tap the canvas to place (works desktop + touch).
- editor/SelectionOverlay.tsx: move (drag body), resize (corner handles,
opposite corner fixed via the object-local frame so rotation is honored),
rotate (knob, snaps to 15°, free with Shift). Each updates liveObject and
fires one PATCH on release.
- editor/Inspector.tsx: name, dimensions/position (unit-aware), rotation,
color override (+ clear), plantable, notes, two-step delete. Commits per
field; re-syncs from the object unless a field is focused.
- GardenCanvas: placement, live-drag merge, z-ordered render, selection
overlay, and ?focus=<id> frames an object on load. GardenEditorPage loads
/full and lays out palette | canvas | inspector (bottom sheet on mobile).
- components/ui/toast.tsx + Toaster in AppShell.
Verified in a real browser against the embedded binary: place a bed → move,
resize, rotate (rotate snapped to 60°) → set its name in the inspector →
reload, all persisted; each gesture was exactly one PATCH (version 1→2→3→4→5);
an out-of-band edit made the next drag 409 → rollback to the server's value +
toast. tsc + 17 vitest tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi