Build image / build-and-push (push) Successful in 9s
Correctness / stuck-state: - PlopInspector: the planted date can now be cleared (blur commits null instead of being blocked); radius/count reset to the current value on invalid input. - applyPlantingPatch now applies removedAt too (completes the optimistic patch). - PlopMarker computes the count live from the current radius, so the on-canvas near-zoom label updates while resizing (not just the inspector). - GardenEditorPage clears focus when the focused object vanishes — deleted, or a stale ?focus id on load — so the canvas can't get stuck dimmed with no exit. - useRemovePlanting adopts the 409 current row, so a stale-version soft-remove can be retried instead of looping. - Placement is gated on plantable, so a deep-linked non-plantable focus can't try to POST a plop the server would 400. Dedup / perf (introduced by this PR): - editor/shared.ts: SELECT_COLOR, HANDLE_PX, MIN_RADIUS_CM, DIMMED_OPACITY, and an objectTransform() helper, now used by ObjectShape/SelectionOverlay/PlopLayer/ PlopOverlay/PlopMarker/GardenCanvas (unified the transform string + the two different dim opacities). - Dropped PlopMarker's always-false `dimmed` prop (dimming is at the group level). - Build plantsById once in the page and pass it to GardenCanvas. - Moved the livePlanting subscription into PlopInspector so a plop drag re-renders only the panel, not the whole page (matches the liveObject pattern). - Memoized PlopLayer's plop grouping so it doesn't rebuild every pan/zoom frame. Skipped: gesture-scaffolding hook extraction (risky churn in #11 code), elliptical clamp for circle objects (consistent with the server's rect bounds), optimistic create (matches useCreateObject). tsc --noEmit clean; 24/24 vitest; production build green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi