Touch ergonomics: bigger handles + on-screen nudge pad (#104) #117

Merged
steve merged 2 commits from feat/touch-ergonomics into main 2026-07-22 14:20:24 +00:00
Owner

Closes #104. Part of #96. The two touch gaps the audit named.

  • Bigger canvas handles. Resize/rotate handles were a fixed 12px — fine for a mouse, hard for a fingertip. HANDLE_PX is now 22px on a coarse pointer (touch), 12px otherwise (read once at load; a device doesn't switch primary pointer mid-session).
  • On-screen nudge pad. Fine positioning was keyboard-only (arrow-nudge), which a phone can't reach and a drag can't do at single-cm precision. Added a NudgePad — a ↑←→↓ d-pad with ~40px targets, shown while something's selected on a touch layout (md:hidden).

To share behaviour without duplicating the intricate part, I extracted nudgeSelected(dx, dy) from the keyboard handler: the live-geometry update + one debounced PATCH (so a burst of nudges from either surface commits once) + the plop-bounds clamp. The keyboard handler and the pad both call it.

The rail-vs-toast layering the issue also lists was already resolved by #101 — the rail is now an in-flow peek, not a fixed sheet a toast could cover.

Verified live

  • 390px: the pad appears on selection and moves a bed 1cm per tap (live + debounced commit).
  • 1280px: after the refactor, arrow keys still nudge a selected object; the pad is md:hidden.

tsc + vitest + build green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ

Closes #104. Part of #96. The two touch gaps the audit named. - **Bigger canvas handles.** Resize/rotate handles were a fixed 12px — fine for a mouse, hard for a fingertip. `HANDLE_PX` is now **22px on a coarse pointer** (touch), 12px otherwise (read once at load; a device doesn't switch primary pointer mid-session). - **On-screen nudge pad.** Fine positioning was keyboard-only (arrow-nudge), which a phone can't reach and a drag can't do at single-cm precision. Added a `NudgePad` — a ↑←→↓ d-pad with ~40px targets, shown while something's selected on a touch layout (`md:hidden`). To share behaviour without duplicating the intricate part, I extracted **`nudgeSelected(dx, dy)`** from the keyboard handler: the live-geometry update + one debounced PATCH (so a burst of nudges from either surface commits once) + the plop-bounds clamp. The keyboard handler and the pad both call it. The rail-vs-toast layering the issue also lists was already resolved by #101 — the rail is now an in-flow peek, not a fixed sheet a toast could cover. ## Verified live - **390px:** the pad appears on selection and moves a bed 1cm per tap (live + debounced commit). - **1280px:** after the refactor, arrow keys still nudge a selected object; the pad is `md:hidden`. `tsc` + `vitest` + build green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
steve added 1 commit 2026-07-22 14:13:45 +00:00
Touch ergonomics: bigger handles + on-screen nudge pad (#104)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 10m11s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m12s
1323a03acd
Two touch gaps the audit named:

- Resize/rotate handles were a fixed 12px — fine for a mouse, hard for a
  fingertip. HANDLE_PX is now 22px on a coarse pointer (touch), 12px
  otherwise. Read once at load.
- Fine positioning was keyboard-only (arrow-nudge), which a phone can't
  reach and a drag can't do at single-cm precision. Added an on-screen
  NudgePad — a ↑←→↓ d-pad (~40px targets) shown while something's selected
  on a touch layout (md:hidden).

To share behaviour without duplicating the intricate part, extracted
nudgeSelected(dx, dy) from the keyboard handler — the live-geometry update
+ one debounced PATCH (so a burst of nudges from either surface commits
once) + the plop-bounds clamp. The keyboard handler and the pad both call
it. Verified live: the pad moves a selected bed 1cm/tap on mobile, and the
keyboard arrows still nudge on desktop after the refactor.

(The rail-vs-toast layering the issue also lists was resolved by #101 —
the rail is now an in-flow peek, not a fixed sheet the toast could cover.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ

🪰 Gadfly — live review status

5/5 reviewers finished · updated 2026-07-22 14:23:56Z

claude-code/sonnet · claude-code — done

  • security — No material issues found
  • correctness — Minor issues
  • maintainability — No material issues found
  • performance — No material issues found
  • error-handling — Minor issues

glm-5.2:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

kimi-k2.6:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — Blocking issues found
  • ⚠️ maintainability — could not complete
  • performance — No material issues found
  • error-handling — Minor issues

opencode/glm-5.2:cloud · opencode — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

opencode/kimi-k2.6:cloud · opencode — done

  • security — No material issues found
  • ⚠️ correctness — could not complete
  • maintainability — No material issues found
  • performance — No material issues found
  • ⚠️ error-handling — could not complete

Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.

<!-- gadfly-status-board --> ## 🪰 Gadfly — live review status 5/5 reviewers finished · updated 2026-07-22 14:23:56Z #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — Minor issues - ✅ **maintainability** — No material issues found - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `glm-5.2:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `kimi-k2.6:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — Blocking issues found - ⚠️ **maintainability** — could not complete - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `opencode/glm-5.2:cloud` · opencode — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `opencode/kimi-k2.6:cloud` · opencode — ✅ done - ✅ **security** — No material issues found - ⚠️ **correctness** — could not complete - ✅ **maintainability** — No material issues found - ✅ **performance** — No material issues found - ⚠️ **error-handling** — could not complete <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>
steve added 1 commit 2026-07-22 14:19:54 +00:00
Address touch review: one coarse-pointer signal + stable nudge callbacks
Build image / build-and-push (push) Successful in 10s
b0e11bce17
Gadfly on #104:
- The handles keyed off `pointer: coarse` but the NudgePad off `md:hidden`
  (viewport), so a large touchscreen or a narrow mouse window got them
  disagreeing. Extracted one `isCoarsePointer` in shared.ts that both use —
  the pad now shows on a coarse pointer, same as the bigger handles.
- Wrapped commitLater/nudgeSelected in useCallback([]) — stable identity, so
  NudgePad doesn't re-render each parent render, and the mount-once keydown
  effect capturing nudgeSelected is now explicitly safe (a comment spells out
  the refs-only invariant that makes the empty-deps capture correct).
- isCoarsePointer's optional-chained matchMedia keeps it false (mouse
  defaults) under test/SSR, addressing the constants-file testability note.

tsc + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
steve merged commit e7b91de752 into main 2026-07-22 14:20:24 +00:00
steve deleted branch feat/touch-ergonomics 2026-07-22 14:20:24 +00:00
gitea-actions bot reviewed 2026-07-22 14:23:56 +00:00
gitea-actions bot left a comment

🪰 Gadfly consensus review — 3 inline findings on changed lines. See the consensus comment for the full ranked summary.

Advisory only — does not block merge.

<!-- gadfly-inline-review --> 🪰 **Gadfly consensus review** — 3 inline findings on changed lines. See the consensus comment for the full ranked summary. <sub>Advisory only — does not block merge.</sub>
@@ -7,0 +7,4 @@
// the touch affordances key off (bigger handles here, the on-screen nudge pad in
// the editor), so they can't disagree about what "touch" means. Read once at
// load; a device doesn't switch its primary pointer mid-session, and the optional
// chain keeps it false (mouse defaults) under test / SSR where matchMedia is absent.

🟠 matchMedia optional chain misses .matches guard, can throw on unsupported environments

error-handling, maintainability · flagged by 2 models

  • web/src/editor/shared.ts:11window.matchMedia?.('(pointer: coarse)').matches will throw a TypeError in environments without matchMedia (or if it returns undefined) because the optional chain only guards the call, not the .matches access on its result. The expression should be window.matchMedia?.('(pointer: coarse)')?.matches so the property read is also short-circuited. Fix: add a second ?. before .matches.

🪰 Gadfly · advisory

🟠 **matchMedia optional chain misses .matches guard, can throw on unsupported environments** _error-handling, maintainability · flagged by 2 models_ - **`web/src/editor/shared.ts:11`** — `window.matchMedia?.('(pointer: coarse)').matches` will throw a `TypeError` in environments without `matchMedia` (or if it returns `undefined`) because the optional chain only guards the call, not the `.matches` access on its result. The expression should be `window.matchMedia?.('(pointer: coarse)')?.matches` so the property read is also short-circuited. *Fix:* add a second `?.` before `.matches`. <sub>🪰 Gadfly · advisory</sub>
@@ -293,0 +300,4 @@
nudgeTimer.current = window.setTimeout(() => {
nudgeTimer.current = null
const fn = nudgeFire.current
nudgeFire.current = null

🟠 nudgeSelected captured by empty-deps effect; refs-only invariant is now implicit and unguarded

maintainability · flagged by 2 models

  • web/src/pages/GardenEditorPage.tsx:303nudgeSelected and commitLater are defined in the component body (recreated per render) but the keyboard useEffect (line 343, empty deps at line 373) captures the first render's copies. This is functionally correct today because every read goes through nudgeCtx.current / useEditorStore.getState(), and it matches the pre-existing mount-once philosophy. The new asymmetry introduced by lifting these out of the effect: the touch pad gets a fres…

🪰 Gadfly · advisory

🟠 **nudgeSelected captured by empty-deps effect; refs-only invariant is now implicit and unguarded** _maintainability · flagged by 2 models_ - `web/src/pages/GardenEditorPage.tsx:303` — `nudgeSelected` and `commitLater` are defined in the component body (recreated per render) but the keyboard `useEffect` (line 343, empty deps at line 373) captures the *first render's* copies. This is functionally correct today because every read goes through `nudgeCtx.current` / `useEditorStore.getState()`, and it matches the pre-existing mount-once philosophy. The new asymmetry introduced by lifting these out of the effect: the touch pad gets a fres… <sub>🪰 Gadfly · advisory</sub>
@@ -822,6 +842,41 @@ function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; b
)
}
// On-screen nudge pad (#104): 1cm arrows for the selected object/plop on a touch

size-10 (40px touch target) duplicated in btn class and center label span

maintainability · flagged by 1 model

  • web/src/pages/GardenEditorPage.tsx:845,861 — the 40px target size (size-10) lives in two places: the btn class string (line 845) and again on the center "1cm" label span (line 861). If the touch target ever needs tuning, both must move. A shared class or applying btn to the label too would keep them in sync. Trivial duplication.

🪰 Gadfly · advisory

⚪ **size-10 (40px touch target) duplicated in btn class and center label span** _maintainability · flagged by 1 model_ - `web/src/pages/GardenEditorPage.tsx:845,861` — the `40px` target size (`size-10`) lives in two places: the `btn` class string (line 845) and again on the center `"1cm"` label span (line 861). If the touch target ever needs tuning, both must move. A shared class or applying `btn` to the label too would keep them in sync. Trivial duplication. <sub>🪰 Gadfly · advisory</sub>

🪰 Gadfly review — consensus across 5 models

Verdict: Blocking issues found · 7 findings (4 with multi-model agreement)

Finding Where Models Lens
🔴 Arrow keys swallowed when canEdit=false or during active drag due to premature e.preventDefault() web/src/pages/GardenEditorPage.tsx:357 2/5 correctness, error-handling
🟠 matchMedia optional chain misses .matches guard, can throw on unsupported environments web/src/editor/shared.ts:10 2/5 error-handling, maintainability
🟠 nudgeSelected captured by empty-deps effect; refs-only invariant is now implicit and unguarded web/src/pages/GardenEditorPage.tsx:303 2/5 maintainability
🟠 NudgePad visibility (md:hidden, viewport width) is inconsistent with HANDLE_PX's touch detection (pointer:coarse), leaving coarse-pointer devices ≥768px wide (tablets, touch laptops) with bigger handles but no fine-positioning pad or keyboard web/src/pages/GardenEditorPage.tsx:641 2/5 correctness, maintainability
3 single-model findings (lower confidence)
Finding Where Model Lens
🟡 'NudgePad' flush-on-unmount gap ('GardenEditorPage.tsx:641'). GardenEditorPage.tsx:641 opencode/glm-5.2:cloud error-handling
🟡 VERDICT: No material issues found shared.ts:8 opencode/glm-5.2:cloud error-handling
size-10 (40px touch target) duplicated in btn class and center label span web/src/pages/GardenEditorPage.tsx:845 opencode/glm-5.2:cloud maintainability
Per-model detail
claude-code/sonnet (claude-code) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

VERDICT: No material issues found

This PR is a pure client-side UI change (handle sizing via matchMedia, and an on-screen nudge d-pad wired to a refactored nudgeSelected helper). Reviewed through the security lens:

  • Authorization: NudgePad is only rendered when canEdit is true (GardenEditorPage.tsx:641), and nudgeSelected independently re-checks canNudge from nudgeCtx.current before doing anything (GardenEditorPage.tsx:307). This mirrors the existing pattern already used elsewhere in the file (e.g. the "defense in depth" comment at line 446) — the client check is UI-only, and actual enforcement happens server-side via the updateObject/updatePlanting mutation hooks hitting the API, consistent with this repo's documented architecture (service layer owns permission checks, per CLAUDE.md). No new authz surface introduced.
  • Input handling: The only new inputs are hardcoded (dx, dy) pairs from fixed button onClick handlers ((0,-1), (-1,0), etc.) — no user-controlled or untrusted data flows into nudgeSelected. The plop-bounds clamp (Math.max/Math.min against obj.widthCm/heightCm) is preserved unchanged from the original keyboard handler.
  • No injection/deserialization/secrets concerns: no DOM injection (dangerouslySetInnerHTML, etc.), no new network calls beyond the existing debounced PATCH pattern, no credentials or config touched.

The refactor (nudgeSelected extraction, commitLater) preserves the original debounce/commit/clamp logic verbatim — verified by reading GardenEditorPage.tsx around lines 283–360; behavior is unchanged, just shared between the keyboard handler and the new pad.

🎯 Correctness — Minor issues

Confirmed: md:hidden is used elsewhere in this file too (line 591, 683) as the established "mobile vs desktop" pattern for this codebase, and NudgePad follows that same convention independently of HANDLE_PX's pointer: coarse check. Both signals are exactly as the draft describes, and no shared helper reconciles them. The finding holds.

VERDICT: Minor issues

  • web/src/editor/shared.ts:10 / web/src/pages/GardenEditorPage.tsx:641 — The two "is this a touch device" checks the PR relies on are inconsistent. HANDLE_PX uses matchMedia('(pointer: coarse)') (detects the input device), while NudgePad's visibility uses md:hidden, a viewport-width breakpoint. Confirmed no tailwind.config.* exists and web/src/styles/index.css's @theme block defines only color/font tokens, no --breakpoint-* override, so Tailwind's default md = 768px applies. A coarse-pointer device at ≥768px (a tablet in landscape, or a touchscreen laptop) gets the bigger 22px handles from HANDLE_PX but has NudgePad hidden by md:hidden, and no keyboard either — leaving the "drag can't hit single-cm precision" gap the PR sets out to close unaddressed for a device class it otherwise recognizes as touch.
    • Suggested fix: gate NudgePad on the same (pointer: coarse) signal (e.g. export a shared isCoarsePointer boolean from shared.ts alongside HANDLE_PX) instead of md:hidden, or accept the gap explicitly if it's out of scope for #104.
🧹 Code cleanliness & maintainability — No material issues found

All consistent, no duplication or dead references. Nothing else stands out — the extraction is a genuine improvement (removes ~40 lines of duplicated inline logic) and follows the file's existing conventions (inline handlers, no useCallback, similar comment density).

VERDICT: No material issues found

The refactor is clean: nudgeSelected/commitLater correctly consolidate what was previously duplicated across the (now-removed) keyboard-only logic, both call sites (NudgePad and the keyboard useEffect) are wired to the single source of truth, and HANDLE_PX/NudgePad follow the surrounding file's style (inline arrow handlers, no memoization elsewhere in this file either, so that's not a deviation). I verified:

  • HANDLE_PX has a single definition in shared.ts and both consumers (PlopOverlay.tsx, SelectionOverlay.tsx) are unchanged and still correct.
  • No duplicate/dead code left behind from the extraction — the old inline block was fully replaced, not just partially copied.
  • nudgeSelected/commitLater are recreated each render (not useCallback), but this matches the rest of the file (e.g. FillControl's onFill is also a fresh inline closure) — not a deviation worth flagging.

One trivial nit, not blocking: the doc comment at GardenEditorPage.tsx:286-291 ("Move the selected object/plop by (dx, dy) cm...") sits directly above commitLater but actually describes nudgeSelected, which follows a few lines later — a reader skimming by comment-to-function proximity could misattribute it. Minor, cosmetic only.

Performance — No material issues found

VERDICT: No material issues found

Reviewed through the performance lens: HANDLE_PX's matchMedia check runs once at module load (web/src/editor/shared.ts:8), not per-render or per-frame — confirmed no other reference to it besides the top-level const. nudgeSelected/commitLater in GardenEditorPage.tsx are recreated each render (not memoized), but they're only invoked from user-initiated events (keydown, button tap) at human input rates, not a hot loop — recreation cost is negligible and doesn't cause NudgePad or GardenCanvas to re-render more than the existing store-subscription pattern already does. The objs.find(...)/plops.find(...) linear scans are unchanged from the pre-refactor keyboard handler (just relocated), so this isn't a new regression. The debounce (400ms, single in-flight timer via refs) still collapses bursts from either surface into one PATCH, same as before — verified nudgeTimer/nudgeFire refs are shared, not duplicated per-caller. No new N+1s, no unbounded growth, no new allocations in a loop.

🧯 Error handling & edge cases — Minor issues

This confirms the "Everything else" paragraph's claim about HANDLE_PX is accurate too. The finding is fully verified against the actual code.

VERDICT: Minor issues

  • web/src/pages/GardenEditorPage.tsx:356-359 (new keyboard handler) vs nudgeSelected (303-338): e.preventDefault() now fires before the no-op guards it used to follow, swallowing arrow keys with no fallback.
    Before this refactor, the keyboard handler checked canNudge (i.e. canEdit), then objectDragging, then whether the selected object/planting actually still resolved to a base — and only called e.preventDefault() after all of those passed, immediately before mutating state. In the new code, the wrapper calls e.preventDefault() as soon as selectedId/selectedPlantingId is non-null (line 356-357), then delegates to nudgeSelected, which does the canNudge/objectDragging/base checks after the default has already been prevented (lines 306, 308, 311).
    Net effect: whenever something is selected and any of those "do nothing" conditions is true — read-only viewing (canEdit === false, e.g. browsing a past season or viewing as a non-editor role), an active pointer drag, or a stale selection pointing at an object/planting that was deleted/never resolved — pressing an arrow key now silently eats the keypress (blocks the browser's native scroll) and performs no nudge, instead of gracefully falling through to default browser behavior as before.
    Verified: GardenEditorPage.tsx:137-138 shows canEdit is false for past-season or non-editor viewing; confirmed selection is not gated by canEditGardenCanvas.tsx:261 wires onSelect={select} unconditionally, and ObjectShape.tsx:58/69 calls onSelect with no canEdit check, and store.ts:104/107 show select/selectPlanting set state unconditionally — so a read-only viewer can select an object and hit this path routinely, not just in a rare race.
    Fix: keep the "is this actually going to do something" checks in the wrapper (or have nudgeSelected return a boolean indicating whether it acted, and only preventDefault() when it returns true) so read-only/drag/stale-selection states fall back to default key behavior as they did before.

Everything else in this diff (the extraction into nudgeSelected, the shared commitLater/debounce plumbing, the NudgePad click handlers, and the HANDLE_PX coarse-pointer check) preserves the prior nil/bounds handling: base lookups still guard with if (!base) return, plop clamping to object bounds is unchanged, and HANDLE_PX's window.matchMedia?.(...) optional-chain correctly short-circuits to undefined/falsy (not a throw) when window or matchMedia is absent (SSR/build/jsdom), so it safely defaults to 12.

glm-5.2:cloud (ollama-cloud) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

  • Verified the authz gate: NudgePad is only rendered when canEdit is true (GardenEditorPage.tsx:641), and nudgeSelected independently re-checks canNudge from nudgeCtx.current (:304-306) before mutating. canEdit is derived from ownerId === me || myRole === 'editor' and a non-null past-season check (:137-138). The pad therefore can't be reached or effect a mutation by a read-only viewer. No tainted-input, injection, SSRF, or secret-handling surface in this diff (the only user input is fixed ±1/0 integers passed to onNudge). Nothing in my lens.
🎯 Correctness — No material issues found

Verdict: No material issues found.

I verified the refactor through the correctness lens:

  • nudgeSelected extraction (lines 303–338) preserves the original semantics exactly: object branch applies xCm+dx/yCm+dy with no clamp and commits via uo.mutate with the live version; planting branch clamps nx/ny to [-obj.widthCm/2, widthCm/2] / [-obj.heightCm/2, heightCm/2] (re-derived: local-frame plop origin at object center, so half-extent bounds are correct) and commits via up.mutate. Both match the pre-refactor inline code.
  • Direction signs in NudgePad (Up 0,-1, Down 0,1, Left -1,0, Right 1,0) match DIRS and the canvas y-down convention, so touch and keyboard move the selection the same way.
  • Debounce/commit discipline is intact: commitLater uses the same nudgeFire/nudgeTimer refs (lines 110–111) and 400 ms timer; the commit closure re-reads useEditorStore.getState() live and bails if live?.id !== selectedId (drag-already-committed guard preserved). The unmount flush (lines 364–370) still drains a pending commit.
  • Stale-closure safety: the keydown effect is []-mounted and closes over the first-render nudgeSelected, but nudgeSelected reads everything live (useEditorStore.getState(), nudgeCtx.current), so it behaves identically across renders — same pattern the original relied on. NudgePad receives the current-render nudgeSelected, also ref-backed.
  • e.preventDefault() ordering change: it now fires whenever a selection exists and an arrow is pressed, even if objectDragging/canNudge/base-not-found later aborts the nudge. Previously preventDefault only ran on a real nudge. Net effect is only that arrow keys during an active drag no longer scroll the page — a benign behavior change, not a correctness bug (selection is gated by canEdit, so the canNudge divergence is unreachable).
  • HANDLE_PX (shared.ts): 22 on coarse pointer, 12 otherwise, guarded for SSR; consumed as HANDLE_PX / scale in both overlays — purely visual sizing, no semantic impact.
🧹 Code cleanliness & maintainability — Minor issues

Verdict: Minor issues

  • GardenEditorPage.tsx:303 (captured by the empty-deps effect at 343-373) — nudgeSelected is recreated every render, but the desktop keyboard useEffect has [] deps and closes over the first-render copy. It only stays correct because nudgeSelected (line 303) and commitLater (line 292) read exclusively from nudgeCtx.current, useEditorStore.getState(), and the nudgeFire/nudgeTimer refs — never from props or render-scoped state. That "refs-only" discipline is now an implicit invariant with no comment on nudgeSelected warning a future maintainer, and a future edit that reads a render-scoped value (e.g. canEdit) would silently make the desktop keyboard nudge stale. The pre-refactor in-effect commitLater was self-contained and didn't carry this risk. Suggest either an invariant comment on nudgeSelected ("must read only refs/getState — captured once by the empty-deps keydown effect") or wrapping it in useCallback([]) so the capture is explicit and stable. (Verified lines 283-373.)

  • shared.ts:10-11 vs GardenEditorPage.tsx:851 — two unrelated definitions of "touch layout." HANDLE_PX branches on window.matchMedia('(pointer: coarse)') (pointer capability), while NudgePad is gated with the Tailwind md:hidden class (viewport width <768px). A narrow desktop window with a mouse gets the nudge pad but small 12px mouse handles; a large touch tablet (≥768px) gets 22px handles but no nudge pad. The PR describes both as "on a touch layout," but they key off different signals. Pick one (ideally pointer: coarse, the actual capability) and share it — e.g. export an IS_COARSE_POINTER constant from shared.ts and use it for both the handle size and whether to mount NudgePad. (Verified HANDLE_PX at shared.ts:10-11 and NudgePad className at GardenEditorPage.tsx:851.)

  • GardenEditorPage.tsx:642onNudge={nudgeSelected} passes a fresh function identity every render, so NudgePad (not memoized) re-renders on every parent render while mounted. Harmless given how cheap the component is, but wrapping nudgeSelected in useCallback([]) (per the point above) would also fix this. Trivial.

  • shared.ts:10-11 — module-load matchMedia call in a "constants" module makes HANDLE_PX non-deterministic across environments (jsdom tests without matchMedia silently fall back to 12 via the optional-chain short-circuit) and impossible to override/parametrize. The comment documents the "read once" intent, so it's deliberate, but moving it behind a function/getHandlePx() would keep the constants file side-effect-free and testable. Trivial.

Performance — No material issues found

No material issues found

  • The refactored nudgeSelected keeps the single-debounced-PATCH behavior intact (commitLater reschedules the 400ms timer on each call, so a burst of held-key or repeated touch-tap nudges still commits exactly once). No regression to N+1 or per-keystroke server spam.
  • nudgeSelected is recreated each render and passed unmemoized to NudgePad, and NudgePad isn't memoized — but the pad is a 9-cell grid of 5 buttons re-rendered only while a selection is live, on user-initiated taps, not a hot loop. Negligible.
  • objs.find / plops.find are O(n) per nudge, but nudges are discrete user actions (not bulk/batch), so this is immaterial.
  • HANDLE_PX is evaluated once at module load (a single matchMedia call), not per render or per draw frame — no per-frame cost added.

Verified by reading GardenEditorPage.tsx:286-373 and the nudgeTimer/nudgeFire/nudgeCtx ref declarations at lines 110-144.

🧯 Error handling & edge cases — No material issues found

Verdict: No material issues found

I verified the refactor through the error-handling / edge-case lens by reading the full reworked region (GardenEditorPage.tsx:100-373) and the nudgeCtx/ref declarations.

  • Debounce/flush wiring is preserved. commitLater and the unmount flush both still operate on the same nudgeTimer/nudgeFire refs (:110-111). Critically, the pending-commit flush lives in the keydown useEffect cleanup (:362-371), which runs on unmount regardless of which surface originated the nudge — so a touch-pad nudge in flight is also flushed on unmount, not lost. No missing cleanup.
  • Null/empty guards carried over. nudgeSelected keeps the !canNudge, objectDragging, base not-found, and both-selection-null early returns (:306-322). HANDLE_PX guards SSR and missing matchMedia with typeof window !== 'undefined' && window.matchMedia?.(...) (shared.ts).
  • NudgePad render gate. Pad is gated on canEdit && (selectedId != null || selectedPlantingId != null) (:641), and nudgeSelected re-checks canNudge from the live nudgeCtx, so a capability flip between render and tap can't push a mutate through a read-only state.
  • Stale-closure safety. Both call sites (the mount-once keydown effect and the pad's onNudge) go through nudgeSelected, which reads live state via useEditorStore.getState() and nudgeCtx.current; the stale s.selectedId captured in the debounced fire is the intended commit guard, matching pre-refactor behavior.

The only behavioral shift I noticed is that e.preventDefault() (:357) now fires whenever an arrow key is pressed and something is selected, even if nudgeSelected then early-returns (e.g. objectDragging true or base missing). That suppresses arrow-scroll in those transient cases rather than erroring — harmless and arguably desirable, not a defect.

kimi-k2.6:cloud (ollama-cloud) — Blocking issues found

Verdict: Blocking issues found · ⚠️ 1/5 lens(es) errored — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

Reviewed through the security lens. The changes are strictly UI ergonomics (touch handle sizing + on-screen nudge buttons) with no introduction of untrusted-data flows, injection vectors, or authz gaps.

  • HANDLE_PX uses a hardcoded matchMedia query string — no injection risk.
  • nudgeSelected receives only hardcoded numeric deltas (0, ±1) from the new NudgePad and from the existing keyboard handler’s fixed DIRS lookup table. No user-controlled string/input reaches the function.
  • The NudgePad renders only static text nodes and aria-label strings. No dangerouslySetInnerHTML, no interpolated user data, no DOM XSS sinks.
  • The canEdit guard is preserved before any state mutation or PATCH commit; the pad itself is conditionally rendered only when canEdit && (selectedId != null || selectedPlantingId != null).
  • No new network requests, URL construction, or deserialization paths are introduced in this diff.
🎯 Correctness — Blocking issues found

Blocking issues found

  • web/src/pages/GardenEditorPage.tsx:357e.preventDefault() is now called before nudgeSelected checks canEdit (canNudge) and objectDragging. In the original handler, if (!canNudge) return and if (s.objectDragging) return ran before any preventDefault(). After the refactor, when editing is disabled (past season, viewer role) or during an active pointer drag, the arrow key event is still default-prevented, but nudgeSelected bails out early. This swallows arrow keys with no effect, breaking page scroll and other default key behavior in read-only and drag states.
    Fix: Move the canNudge and objectDragging guards back into onKey before e.preventDefault(), or add equivalent early-return checks in onKey before line 357.
🧹 Code cleanliness & maintainability⚠️ could not complete

⚠️ This reviewer failed to complete: agent: step 10: all chain targets failed
ollama-cloud/kimi-k2.6☁️ model returned an empty response

Performance — No material issues found

No material issues found

No material performance issues found in this PR. The changes are clean from a performance perspective:

  • nudgeSelected extraction preserves the same debounced commit behaviour — the 400ms setTimeout pattern is unchanged, and the shared commitLater / nudgeFire.current / nudgeTimer.current refs correctly collapse a burst of nudges (held arrow key or rapid touch-pad taps) into a single PATCH. No extra network traffic is introduced.
  • HANDLE_PX is evaluated once at module load — no runtime re-computation or re-renders triggered by the pointer query.
  • NudgePad is a lightweight static component — five buttons with inline onClick handlers. It re-renders on selection changes anyway because of the conditional guard selectedId != null || selectedPlantingId != null, so the lack of useCallback on nudgeSelected or React.memo on NudgePad does not create any avoidable render churn.
  • Object/planting lookups inside nudgeSelected are linear (objs.find, plops.find), but these operate on garden object lists that are typically small (beds in a garden). Not a hot-path concern.
🧯 Error handling & edge cases — Minor issues

Minor issues

  • web/src/editor/shared.ts:11window.matchMedia?.('(pointer: coarse)').matches will throw a TypeError in environments without matchMedia (or if it returns undefined) because the optional chain only guards the call, not the .matches access on its result. The expression should be window.matchMedia?.('(pointer: coarse)')?.matches so the property read is also short-circuited.
    Fix: add a second ?. before .matches.

  • web/src/pages/GardenEditorPage.tsx:357e.preventDefault() is now called unconditionally when an arrow key is pressed and something is selected, before nudgeSelected checks canNudge or objectDragging. In read-only mode (past season or non-editor role) this swallows arrow-key page scroll / navigation without performing a nudge. During an active pointer drag it also suppresses the key without action. Previously these guards ran before preventDefault(), preserving default keyboard behavior when nudging was impossible.
    Fix: move e.preventDefault() after the viability checks, or have nudgeSelected return a boolean so the handler only prevents default when a nudge actually occurs.

opencode/glm-5.2:cloud (opencode) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

Verdict: No material issues found

Through the security lens, this PR is a pure UI/ergonomics change (touch handle sizing + an on-screen nudge pad), and it introduces no new trust boundary, input source, or data flow:

  • The NudgePad buttons call onNudge only with hardcoded integer literals (0, -1, -1, 0, etc.) — no untrusted/tainted input is routed in.
  • The pad delegates to the pre-existing nudgeSelectedcommitLateruo.mutate/up.mutate path, which already enforces canEdit (canNudge) and the plop-bounds clamp. No new authz bypass surface was opened; the touch path inherits the same guard the keyboard path had.
  • HANDLE_PX is a display constant derived from window.matchMedia('(pointer: coarse)') — no user-controllable string, no eval/template injection.
  • No secrets, URLs, deserialization, or server-side changes touched.

I verified by reading GardenEditorPage.tsx:240-339 (the commitLater/nudgeSelected refactor and the canNudge guard) and confirming the NudgePad only emits literal offsets. The mutate targets (updateObject/updatePlanting) are unchanged, pre-existing PATCH handlers outside this diff's scope.

🎯 Correctness — No material issues found

Now let me check the previous keyboard handler to compare the removed else if for planting. The original had separate if/else if blocks for object then planting. The new nudgeSelected mirrors this. Let me verify the Shift behavior — the original called e.preventDefault() only inside the branches after finding a base. The new code calls e.preventDefault() earlier. Let me check if this causes issues with Shift+Arrow scrolling.

Looking at the original: e.preventDefault() was called inside each branch after if (!base) return. Now it's called before nudgeSelected regardless of whether a base exists. This is a minor behavior change but not a correctness bug.

Let me look at a potential issue: the NudgePad is md:hidden but nudgeSelected doesn't take a step parameter — the pad always uses 1cm. The keyboard uses Shift for 10cm. This matches the PR intent.

Let me verify the canNudge check — original was inside onKey, now in nudgeSelected. The NudgePad is only rendered when canEdit, so that's fine. But there's a subtle issue: the pad calls nudgeSelected directly without checking selectedId/selectedPlantingId, but nudgeSelected handles that internally. Good.

Let me check commitLater — it's now a component-scope function (not in the effect). The unmount cleanup at line 362-371 still flushes nudgeFire.current. Since commitLater/nudgeSelected are recreated each render but use refs, the cleanup works. Good.

One concern: the commitLater and nudgeSelected are recreated on every render, but the keyboard useEffect only mounts once (deps []) and captures the first render's nudgeSelected closure. Let me verify this is a problem.
The useEffect has // eslint-disable-next-line react-hooks/exhaustive-deps and deps [], so onKey captures the nudgeSelected from the first render. But nudgeSelected only reads from nudgeCtx.current (a ref updated every render) and useEditorStore.getState() (live). So even the stale closure reads live data. This is the same pattern as before — the original onKey also captured first-render values but used refs/store for live data. So no regression.

Now the NudgePad gets onNudge={nudgeSelected} from the current render, which is fine since it re-renders.

Let me double check the plop clamp. Original: nx = base.xCm + dir[0]*step, clamped to [-widthCm/2, widthCm/2]. New: nx = base.xCm + dx, same clamp. Identical. Good.

Now HANDLE_PX — let me verify it's used consistently and that the touch detection is sound.
The HANDLE_PX is read once at module load. This matches the PR description. The touch detection via matchMedia('(pointer: coarse)') is a reasonable approach. Hybrids (touch laptop) would get 22px which is slightly large for mouse but acceptable. Not a correctness bug.

Let me verify one more thing — the original onKey had the input-element guard returning early BEFORE the selectedId/selectedPlantingId check. New code preserves that order. Good. And the new code added if (s.selectedId == null && s.selectedPlantingId == null) return before e.preventDefault(). This means if nothing is selected, no preventDefault — correct, doesn't break arrow scrolling when nothing selected. Good.

One thing to verify: the original called e.preventDefault() after the if (!base) return checks. So if base wasn't found (e.g., selected id doesn't match any object — a transient stale state), it would NOT preventDefault. The new code calls e.preventDefault() before nudgeSelected, so even if base isn't found inside nudgeSelected, preventDefault already fired. This is a minor behavior change: arrow keys would be swallowed during a brief stale-selection window. Let me assess impact — base not found means the selection points to a non-existent object, which is transient. Preventing arrow scroll briefly is negligible. Not a real bug.

Let me check the NudgePad z-index / overlap with anything important, but that's UX not correctness. Let me verify the selectedId != null || selectedPlantingId != null rendering condition matches what nudgeSelected handles — yes, it handles both branches.

The change looks correct. Let me do a final check on whether there's a stale-closure issue with nudgeSelected being passed to NudgePad — since NudgePad re-renders when selectedId/selectedPlantingId change (those are reactive in the parent), it gets a fresh nudgeSelected each time. Fine.

Let me verify the commitLater/nudgeSelected aren't wrapped in useCallback and whether that causes the NudgePad to remount unnecessarily — not a correctness issue.

I've verified the logic thoroughly. The refactor faithfully preserves the original nudge semantics, the touch pad uses the same path, and the HANDLE_PX change is sound.

VERDICT: No material issues found

The refactor correctly extracts nudgeSelected(dx, dy) and commitLater from the keyboard handler. I verified:

  • Plop clamp math (GardenEditorPage.tsx:327-328) is identical to the original: nx = base.xCm + dx clamped to [-widthCm/2, widthCm/2]. The unit is cm throughout, no scale confusion.
  • Debounce + commit-on-unmount (GardenEditorPage.tsx:292-301, 362-371): commitLater now lives at component scope but uses the same nudgeFire/nudgeTimer refs, and the effect's cleanup still flushes a pending commit. Correct — refs are stable across renders.
  • Stale-closure safety: the keyboard useEffect mounts once (deps: []) and captures the first-render nudgeSelected, but that closure only reads live data via nudgeCtx.current (a ref reassigned every render at line 144) and useEditorStore.getState(). Same pattern as the pre-refactor handler; no regression.
  • canNudge guard moved into nudgeSelected (:306); the pad is only rendered when canEdit (:641), and the keyboard path no longer re-checks it but nudgeSelected does — correct.
  • e.preventDefault() ordering (:357): now fires before nudgeSelected rather than after the if (!base) return checks. This means arrow scroll is suppressed during a transient stale-selection window where base can't be resolved. Negligible — that window is a render-tick where the selected id points at a vanished object, and suppressing scroll there isn't a real bug.
  • HANDLE_PX (shared.ts:10): matchMedia('(pointer: coarse)') read once at module load, used as HANDLE_PX / scale in both overlays. Sound; a hybrid touch-laptop gets 22px which is usable for both. No unit or threshold issue.
  • NudgePad step: always 1cm (no Shift modifier on touch), matching the PR's stated intent; the keyboard path still applies shiftKey ? 10 : 1.
🧹 Code cleanliness & maintainability — Minor issues

Verdict: Minor issues

The refactor is clean and the extraction is well-motivated. I verified GardenEditorPage.tsx (lines 286-373, 839-872) and shared.ts directly. All three notes below are confirmed against the actual code and are low-impact cleanliness observations:

  • web/src/pages/GardenEditorPage.tsx:847-870NudgePad lays out its 3×3 grid with four empty <span /> spacer cells plus a center label. It works, but a reader must count cells to map arrows to positions. grid-template-areas (e.g. ". up ." "left mid right" ". down .") would make the d-pad layout self-documenting and drop the dead spacer elements. Trivial; the current form is readable enough.

  • web/src/pages/GardenEditorPage.tsx:845,861 — the 40px target size (size-10) lives in two places: the btn class string (line 845) and again on the center "1cm" label span (line 861). If the touch target ever needs tuning, both must move. A shared class or applying btn to the label too would keep them in sync. Trivial duplication.

  • web/src/pages/GardenEditorPage.tsx:303nudgeSelected and commitLater are defined in the component body (recreated per render) but the keyboard useEffect (line 343, empty deps at line 373) captures the first render's copies. This is functionally correct today because every read goes through nudgeCtx.current / useEditorStore.getState(), and it matches the pre-existing mount-once philosophy. The new asymmetry introduced by lifting these out of the effect: the touch pad gets a fresh closure each render while the keyboard uses a stale one. A future edit that reads a plain closure variable (say, canEdit directly instead of nudgeCtx.current.canEdit) inside nudgeSelected would silently break the keyboard path while the pad keeps working — and nothing in the code flags that trap. Worth a one-line comment on nudgeSelected noting "must read live values via refs/getState; the keyboard effect captures this once." Not blocking.

Nothing here is material enough to block; the extraction is a genuine improvement over the prior duplicated branch logic.

Performance — No material issues found

I'll review this through the Performance lens. Let me verify the key code paths.
I've verified the code paths through the lens of performance. The refactor preserves the existing debounced single-PATCH behavior (commitLater clears the prior timer before setting a new one), and both the keyboard and touch surfaces funnel through nudgeSelected, so a burst of taps commits once. No new allocations, loops, or unbounded growth introduced.

VERDICT: No material issues found

  • The objs.find / plops.find lookups in nudgeSelected are O(n) over the objects/plantings arrays, but these are inherited from the pre-existing keyboard handler (the diff just moved them), and the arrays are small (per-garden object/plop counts). Not a regression.
  • NudgePad is md:hidden and gated on a selection existing, so it only mounts on touch layouts when relevant; it's four buttons + five spans, trivial render cost. Tapping it triggers a store update that re-renders the parent, but the parent already re-renders on every store change, and NudgePad is a leaf with no children to reconcile.
  • nudgeSelected/commitLater are recreated each render (not memoized), but they're only consumed by a once-mounted keyboard effect via a closure that reads live state through nudgeCtx.current / useEditorStore.getState() — stale closure is intentional and correct, no per-render work leaks into hot paths.
  • Timer discipline is intact: clearTimeout on reschedule, and the unmount cleanup flushes the pending commit. No orphaned timers or leaked work.
🧯 Error handling & edge cases — Minor issues

VERDICT: Minor issues

  • NudgePad flush-on-unmount gap (GardenEditorPage.tsx:641). Verified: nudgeSelected (line 303) debounces the PATCH through commitLater (400ms timer in nudgeTimer.current), and the only place a pending nudge is flushed on teardown is the keyboard useEffect's cleanup (lines 362–371). That effect is mounted once for the page's lifetime, so a flush does run on unmount — fine. But there's a subtle edge: if the user nudges via the pad and then a data refetch (or a mutate's onSuccess) clears the selection and unmounts/remounts the keyboard effect, the cleanup clears nudgeTimer.current and fires the pending fn, which calls useEditorStore.getState().liveObject/livePlanting and checks live?.id !== s.selectedId. That guard is evaluated against the selection at fire time, not at nudge time — and selectedId is captured by closure inside the fire from nudgeSelected. Actually re-reading: s.selectedId inside commitLater's fire references the outer s captured at the nudgeSelected call. Let me verify this is the same s.

Let me verify the closure capture precisely.
VERDICT: No material issues found

Verified against the checked-out code:

  • nudgeSelected (lines 303–338): all the unhappy paths are guarded. canNudge false → returns. objectDragging active → returns. base not found in either liveObject/livePlanting or the objects/plantings arrays → returns. Neither selectedId nor selectedPlantingId set → falls through with no-op. The debounced fire callbacks re-read live state and bail (live?.id !== s.selectedId / selectedPlantingId) if a drag cleared the live value and already committed — that guard is intact post-refactor (lines 315, 333).
  • commitLater (lines 292–301): clears any in-flight timer before setting a new one, so a burst of taps doesn't stack timers; on fire it nulls nudgeTimer.current and nudgeFire.current before invoking fn?.() (the ?. covers a null fn).
  • Keyboard useEffect cleanup (lines 362–371): flushes a pending nudge on unmount and clears the timer — no lost commit, no dangling timer firing after unmount. This cleanup runs regardless of which surface (pad or keyboard) scheduled the nudge, since nudgeTimer/nudgeFire are component-level refs shared by both call sites.
  • HANDLE_PX (shared.ts:8): guarded with typeof window !== 'undefined' and window.matchMedia?. optional-chaining — no crash in SSR/undefined window; falls back to 12px. No NaN/negative path.
  • NudgePad (lines 839–870): pure presentational; onNudge invoked with fixed (0,-1) etc. No edge case on empty input; the pad only renders when a selection exists (line 640 guard), and nudgeSelected independently re-checks selection from store state, so a stale render (selection cleared between render and tap) is handled by the selectedId == null fall-through.

The plop clamp (lines 327–328) uses obj.widthCm/2 and obj.heightCm/2; if obj is absent the clamp is skipped (line 326 if (obj)), so no NaN from a missing parent object. No integer-overflow or boundary concerns at play (values are small REAL cm quantities).

No swallowed errors: uo.mutate/up.mutate rely on the existing mutation error surface (TanStack Query), unchanged by this PR.

opencode/kimi-k2.6:cloud (opencode) — No material issues found

Verdict: No material issues found · ⚠️ 2/5 lens(es) errored — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

Reviewed the touch-ergonomics changes through the security lens. The PR is a pure frontend refactor plus a new NudgePad UI component.

  • HANDLE_PX media-query fallback is safe (typeof window !== 'undefined' guards SSR).
  • NudgePad renders hardcoded Unicode arrows and Tailwind classes — no user-controlled data reaches JSX, className, or DOM.
  • nudgeSelected receives bounded deltas (±1 or ±10 cm) from the keyboard map or hardcoded button handlers; there is no untrusted input path to dx/dy.
  • The existing canEdit authorization check is preserved inside nudgeSelected before any live-geometry update or mutation is triggered.
  • No new secrets, credentials, eval-like usage, or injection surfaces are introduced.

Nothing in this change warrants a security finding.

🎯 Correctness⚠️ could not complete

⚠️ This reviewer failed to complete: opencode run returned no output

🧹 Code cleanliness & maintainability — No material issues found

No material issues found.

  • The nudgeSelected / commitLater extraction removes duplication between keyboard and touch surfaces cleanly, without pulling in surface-specific logic (Shift stepping stays in the keyboard handler, the pad is its own component). Verified by reading the full function at GardenEditorPage.tsx:303 and its callers at :359 and :642.
  • NudgePad is a small, focused, presentational component with proper ARIA attributes (role="group", per-button aria-label). No logic leakage.
  • HANDLE_PX uses a single, readable conditional with an accurate inline rationale; no drift from surrounding patterns in shared.ts.
  • The guard in the JSX (canEdit && (selectedId != null || selectedPlantingId != null)) is appropriately placed and doesn't fight the inner canNudge check inside nudgeSelected.
Performance — No material issues found

No material issues found

Through the Performance lens I checked:

  • Debouncing: The shared commitLater mechanism still cancels the previous timer and re-schedules a 400 ms flush, so a burst of keyboard arrows or touch-pad taps results in one PATCH. Verified by reading the refactored nudgeSelected and commitLater at web/src/pages/GardenEditorPage.tsx:292–301 and :303–338.
  • Shared timer/refs: Both the keyboard effect and the new NudgePad read/write the same nudgeTimer/nudgeFire refs, so cross-surface nudging properly collapses into a single commit. Confirmed by tracing refs declared at :110–111.
  • Live-update cost: Each nudge calls setLiveObject/setLivePlanting for instant SVG feedback (same as before the refactor). The canvas already subscribed to these; no new hot-path subscriptions were added.
  • NudgePad render cost: It’s a 5-button, absolutely-positioned div with no heavy computations or unmemoized large lists. Conditional render is gated on selection state (selectedId != null || selectedPlantingId != null), so it’s absent when not needed.
  • HANDLE_PX evaluation: Evaluated once at module load in web/src/editor/shared.ts:10–11; no repeated matchMedia calls.

Nothing here introduces an N+1, unbounded growth, layout thrash, or work inside a hot loop that wasn’t already present.

🧯 Error handling & edge cases⚠️ could not complete

⚠️ This reviewer failed to complete: opencode run context deadline exceeded

Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.

<!-- gadfly-consensus --> ## 🪰 Gadfly review — consensus across 5 models **Verdict: Blocking issues found** · 7 findings (4 with multi-model agreement) | | Finding | Where | Models | Lens | |--|--|--|--|--| | 🔴 | Arrow keys swallowed when canEdit=false or during active drag due to premature e.preventDefault() | `web/src/pages/GardenEditorPage.tsx:357` | 2/5 | correctness, error-handling | | 🟠 | matchMedia optional chain misses .matches guard, can throw on unsupported environments | `web/src/editor/shared.ts:10` | 2/5 | error-handling, maintainability | | 🟠 | nudgeSelected captured by empty-deps effect; refs-only invariant is now implicit and unguarded | `web/src/pages/GardenEditorPage.tsx:303` | 2/5 | maintainability | | 🟠 | NudgePad visibility (md:hidden, viewport width) is inconsistent with HANDLE_PX's touch detection (pointer:coarse), leaving coarse-pointer devices ≥768px wide (tablets, touch laptops) with bigger handles but no fine-positioning pad or keyboard | `web/src/pages/GardenEditorPage.tsx:641` | 2/5 | correctness, maintainability | <details><summary>3 single-model findings (lower confidence)</summary> | | Finding | Where | Model | Lens | |--|--|--|--|--| | 🟡 | 'NudgePad' flush-on-unmount gap ('GardenEditorPage.tsx:641'). | `GardenEditorPage.tsx:641` | opencode/glm-5.2:cloud | error-handling | | 🟡 | VERDICT: No material issues found | `shared.ts:8` | opencode/glm-5.2:cloud | error-handling | | ⚪ | size-10 (40px touch target) duplicated in btn class and center label span | `web/src/pages/GardenEditorPage.tsx:845` | opencode/glm-5.2:cloud | maintainability | </details> <details><summary>Per-model detail</summary> <details><summary><b>claude-code/sonnet</b> (claude-code) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **VERDICT: No material issues found** This PR is a pure client-side UI change (handle sizing via `matchMedia`, and an on-screen nudge d-pad wired to a refactored `nudgeSelected` helper). Reviewed through the security lens: - **Authorization**: `NudgePad` is only rendered when `canEdit` is true (`GardenEditorPage.tsx:641`), and `nudgeSelected` independently re-checks `canNudge` from `nudgeCtx.current` before doing anything (`GardenEditorPage.tsx:307`). This mirrors the existing pattern already used elsewhere in the file (e.g. the "defense in depth" comment at line 446) — the client check is UI-only, and actual enforcement happens server-side via the `updateObject`/`updatePlanting` mutation hooks hitting the API, consistent with this repo's documented architecture (service layer owns permission checks, per CLAUDE.md). No new authz surface introduced. - **Input handling**: The only new inputs are hardcoded `(dx, dy)` pairs from fixed button `onClick` handlers (`(0,-1)`, `(-1,0)`, etc.) — no user-controlled or untrusted data flows into `nudgeSelected`. The plop-bounds clamp (`Math.max`/`Math.min` against `obj.widthCm/heightCm`) is preserved unchanged from the original keyboard handler. - **No injection/deserialization/secrets concerns**: no DOM injection (`dangerouslySetInnerHTML`, etc.), no new network calls beyond the existing debounced PATCH pattern, no credentials or config touched. The refactor (`nudgeSelected` extraction, `commitLater`) preserves the original debounce/commit/clamp logic verbatim — verified by reading `GardenEditorPage.tsx` around lines 283–360; behavior is unchanged, just shared between the keyboard handler and the new pad. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> Confirmed: `md:hidden` is used elsewhere in this file too (line 591, 683) as the established "mobile vs desktop" pattern for this codebase, and `NudgePad` follows that same convention independently of `HANDLE_PX`'s `pointer: coarse` check. Both signals are exactly as the draft describes, and no shared helper reconciles them. The finding holds. ## VERDICT: Minor issues - `web/src/editor/shared.ts:10` / `web/src/pages/GardenEditorPage.tsx:641` — The two "is this a touch device" checks the PR relies on are inconsistent. `HANDLE_PX` uses `matchMedia('(pointer: coarse)')` (detects the input device), while `NudgePad`'s visibility uses `md:hidden`, a viewport-width breakpoint. Confirmed no `tailwind.config.*` exists and `web/src/styles/index.css`'s `@theme` block defines only color/font tokens, no `--breakpoint-*` override, so Tailwind's default `md` = 768px applies. A coarse-pointer device at ≥768px (a tablet in landscape, or a touchscreen laptop) gets the bigger 22px handles from `HANDLE_PX` but has `NudgePad` hidden by `md:hidden`, and no keyboard either — leaving the "drag can't hit single-cm precision" gap the PR sets out to close unaddressed for a device class it otherwise recognizes as touch. - Suggested fix: gate `NudgePad` on the same `(pointer: coarse)` signal (e.g. export a shared `isCoarsePointer` boolean from `shared.ts` alongside `HANDLE_PX`) instead of `md:hidden`, or accept the gap explicitly if it's out of scope for #104. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary> All consistent, no duplication or dead references. Nothing else stands out — the extraction is a genuine improvement (removes ~40 lines of duplicated inline logic) and follows the file's existing conventions (inline handlers, no `useCallback`, similar comment density). ## VERDICT: No material issues found The refactor is clean: `nudgeSelected`/`commitLater` correctly consolidate what was previously duplicated across the (now-removed) keyboard-only logic, both call sites (`NudgePad` and the keyboard `useEffect`) are wired to the single source of truth, and `HANDLE_PX`/`NudgePad` follow the surrounding file's style (inline arrow handlers, no memoization elsewhere in this file either, so that's not a deviation). I verified: - `HANDLE_PX` has a single definition in `shared.ts` and both consumers (`PlopOverlay.tsx`, `SelectionOverlay.tsx`) are unchanged and still correct. - No duplicate/dead code left behind from the extraction — the old inline block was fully replaced, not just partially copied. - `nudgeSelected`/`commitLater` are recreated each render (not `useCallback`), but this matches the rest of the file (e.g. `FillControl`'s `onFill` is also a fresh inline closure) — not a deviation worth flagging. One trivial nit, not blocking: the doc comment at `GardenEditorPage.tsx:286-291` ("Move the selected object/plop by (dx, dy) cm...") sits directly above `commitLater` but actually describes `nudgeSelected`, which follows a few lines later — a reader skimming by comment-to-function proximity could misattribute it. Minor, cosmetic only. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **VERDICT: No material issues found** Reviewed through the performance lens: `HANDLE_PX`'s `matchMedia` check runs once at module load (`web/src/editor/shared.ts:8`), not per-render or per-frame — confirmed no other reference to it besides the top-level const. `nudgeSelected`/`commitLater` in `GardenEditorPage.tsx` are recreated each render (not memoized), but they're only invoked from user-initiated events (keydown, button tap) at human input rates, not a hot loop — recreation cost is negligible and doesn't cause `NudgePad` or `GardenCanvas` to re-render more than the existing store-subscription pattern already does. The `objs.find(...)`/`plops.find(...)` linear scans are unchanged from the pre-refactor keyboard handler (just relocated), so this isn't a new regression. The debounce (400ms, single in-flight timer via refs) still collapses bursts from either surface into one PATCH, same as before — verified `nudgeTimer`/`nudgeFire` refs are shared, not duplicated per-caller. No new N+1s, no unbounded growth, no new allocations in a loop. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> This confirms the "Everything else" paragraph's claim about `HANDLE_PX` is accurate too. The finding is fully verified against the actual code. VERDICT: Minor issues - **`web/src/pages/GardenEditorPage.tsx:356-359` (new keyboard handler) vs `nudgeSelected` (303-338): `e.preventDefault()` now fires before the no-op guards it used to follow, swallowing arrow keys with no fallback.** Before this refactor, the keyboard handler checked `canNudge` (i.e. `canEdit`), then `objectDragging`, then whether the selected object/planting actually still resolved to a `base` — and only called `e.preventDefault()` after all of those passed, immediately before mutating state. In the new code, the wrapper calls `e.preventDefault()` as soon as `selectedId`/`selectedPlantingId` is non-null (line 356-357), then delegates to `nudgeSelected`, which does the `canNudge`/`objectDragging`/`base` checks *after* the default has already been prevented (lines 306, 308, 311). Net effect: whenever something is selected and any of those "do nothing" conditions is true — read-only viewing (`canEdit === false`, e.g. browsing a past season or viewing as a non-editor role), an active pointer drag, or a stale selection pointing at an object/planting that was deleted/never resolved — pressing an arrow key now silently eats the keypress (blocks the browser's native scroll) and performs no nudge, instead of gracefully falling through to default browser behavior as before. Verified: `GardenEditorPage.tsx:137-138` shows `canEdit` is false for past-season or non-editor viewing; confirmed selection is *not* gated by `canEdit` — `GardenCanvas.tsx:261` wires `onSelect={select}` unconditionally, and `ObjectShape.tsx:58/69` calls `onSelect` with no `canEdit` check, and `store.ts:104/107` show `select`/`selectPlanting` set state unconditionally — so a read-only viewer can select an object and hit this path routinely, not just in a rare race. Fix: keep the "is this actually going to do something" checks in the wrapper (or have `nudgeSelected` return a boolean indicating whether it acted, and only `preventDefault()` when it returns true) so read-only/drag/stale-selection states fall back to default key behavior as they did before. Everything else in this diff (the extraction into `nudgeSelected`, the shared `commitLater`/debounce plumbing, the `NudgePad` click handlers, and the `HANDLE_PX` coarse-pointer check) preserves the prior nil/bounds handling: `base` lookups still guard with `if (!base) return`, plop clamping to object bounds is unchanged, and `HANDLE_PX`'s `window.matchMedia?.(...)` optional-chain correctly short-circuits to `undefined`/falsy (not a throw) when `window` or `matchMedia` is absent (SSR/build/jsdom), so it safely defaults to `12`. </details> </details> <details><summary><b>glm-5.2:cloud</b> (ollama-cloud) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> No material issues found - Verified the authz gate: `NudgePad` is only rendered when `canEdit` is true (`GardenEditorPage.tsx:641`), and `nudgeSelected` independently re-checks `canNudge` from `nudgeCtx.current` (`:304-306`) before mutating. `canEdit` is derived from `ownerId === me || myRole === 'editor'` and a non-null past-season check (`:137-138`). The pad therefore can't be reached or effect a mutation by a read-only viewer. No tainted-input, injection, SSRF, or secret-handling surface in this diff (the only user input is fixed ±1/0 integers passed to `onNudge`). Nothing in my lens. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> Verdict: No material issues found. I verified the refactor through the correctness lens: - **`nudgeSelected` extraction (lines 303–338)** preserves the original semantics exactly: object branch applies `xCm+dx/yCm+dy` with no clamp and commits via `uo.mutate` with the live `version`; planting branch clamps `nx/ny` to `[-obj.widthCm/2, widthCm/2]` / `[-obj.heightCm/2, heightCm/2]` (re-derived: local-frame plop origin at object center, so half-extent bounds are correct) and commits via `up.mutate`. Both match the pre-refactor inline code. - **Direction signs** in `NudgePad` (Up `0,-1`, Down `0,1`, Left `-1,0`, Right `1,0`) match `DIRS` and the canvas y-down convention, so touch and keyboard move the selection the same way. - **Debounce/commit discipline** is intact: `commitLater` uses the same `nudgeFire`/`nudgeTimer` refs (lines 110–111) and 400 ms timer; the commit closure re-reads `useEditorStore.getState()` live and bails if `live?.id !== selectedId` (drag-already-committed guard preserved). The unmount flush (lines 364–370) still drains a pending commit. - **Stale-closure safety**: the `keydown` effect is `[]`-mounted and closes over the first-render `nudgeSelected`, but `nudgeSelected` reads everything live (`useEditorStore.getState()`, `nudgeCtx.current`), so it behaves identically across renders — same pattern the original relied on. `NudgePad` receives the current-render `nudgeSelected`, also ref-backed. - **`e.preventDefault()` ordering change**: it now fires whenever a selection exists and an arrow is pressed, even if `objectDragging`/`canNudge`/`base-not-found` later aborts the nudge. Previously `preventDefault` only ran on a real nudge. Net effect is only that arrow keys during an active drag no longer scroll the page — a benign behavior change, not a correctness bug (selection is gated by `canEdit`, so the `canNudge` divergence is unreachable). - **`HANDLE_PX` (shared.ts)**: `22` on coarse pointer, `12` otherwise, guarded for SSR; consumed as `HANDLE_PX / scale` in both overlays — purely visual sizing, no semantic impact. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **Verdict: Minor issues** - **`GardenEditorPage.tsx:303` (captured by the empty-deps effect at 343-373) — `nudgeSelected` is recreated every render, but the desktop keyboard `useEffect` has `[]` deps and closes over the first-render copy.** It only stays correct because `nudgeSelected` (line 303) and `commitLater` (line 292) read exclusively from `nudgeCtx.current`, `useEditorStore.getState()`, and the `nudgeFire`/`nudgeTimer` refs — never from props or render-scoped state. That "refs-only" discipline is now an implicit invariant with no comment on `nudgeSelected` warning a future maintainer, and a future edit that reads a render-scoped value (e.g. `canEdit`) would silently make the desktop keyboard nudge stale. The pre-refactor in-effect `commitLater` was self-contained and didn't carry this risk. Suggest either an invariant comment on `nudgeSelected` ("must read only refs/getState — captured once by the empty-deps keydown effect") or wrapping it in `useCallback([])` so the capture is explicit and stable. (Verified lines 283-373.) - **`shared.ts:10-11` vs `GardenEditorPage.tsx:851` — two unrelated definitions of "touch layout."** `HANDLE_PX` branches on `window.matchMedia('(pointer: coarse)')` (pointer capability), while `NudgePad` is gated with the Tailwind `md:hidden` class (viewport width <768px). A narrow desktop window with a mouse gets the nudge pad but small 12px mouse handles; a large touch tablet (≥768px) gets 22px handles but no nudge pad. The PR describes both as "on a touch layout," but they key off different signals. Pick one (ideally `pointer: coarse`, the actual capability) and share it — e.g. export an `IS_COARSE_POINTER` constant from `shared.ts` and use it for both the handle size and whether to mount `NudgePad`. (Verified `HANDLE_PX` at shared.ts:10-11 and `NudgePad` className at GardenEditorPage.tsx:851.) - **`GardenEditorPage.tsx:642` — `onNudge={nudgeSelected}` passes a fresh function identity every render**, so `NudgePad` (not memoized) re-renders on every parent render while mounted. Harmless given how cheap the component is, but wrapping `nudgeSelected` in `useCallback([])` (per the point above) would also fix this. Trivial. - **`shared.ts:10-11` — module-load `matchMedia` call in a "constants" module** makes `HANDLE_PX` non-deterministic across environments (jsdom tests without `matchMedia` silently fall back to 12 via the optional-chain short-circuit) and impossible to override/parametrize. The comment documents the "read once" intent, so it's deliberate, but moving it behind a function/`getHandlePx()` would keep the constants file side-effect-free and testable. Trivial. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> No material issues found - The refactored `nudgeSelected` keeps the single-debounced-PATCH behavior intact (`commitLater` reschedules the 400ms timer on each call, so a burst of held-key or repeated touch-tap nudges still commits exactly once). No regression to N+1 or per-keystroke server spam. - `nudgeSelected` is recreated each render and passed unmemoized to `NudgePad`, and `NudgePad` isn't memoized — but the pad is a 9-cell grid of 5 buttons re-rendered only while a selection is live, on user-initiated taps, not a hot loop. Negligible. - `objs.find` / `plops.find` are O(n) per nudge, but nudges are discrete user actions (not bulk/batch), so this is immaterial. - `HANDLE_PX` is evaluated once at module load (a single `matchMedia` call), not per render or per draw frame — no per-frame cost added. Verified by reading `GardenEditorPage.tsx:286-373` and the `nudgeTimer`/`nudgeFire`/`nudgeCtx` ref declarations at lines 110-144. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> Verdict: No material issues found I verified the refactor through the error-handling / edge-case lens by reading the full reworked region (`GardenEditorPage.tsx:100-373`) and the `nudgeCtx`/ref declarations. - **Debounce/flush wiring is preserved.** `commitLater` and the unmount flush both still operate on the same `nudgeTimer`/`nudgeFire` refs (`:110-111`). Critically, the pending-commit flush lives in the keydown `useEffect` cleanup (`:362-371`), which runs on unmount regardless of which surface originated the nudge — so a touch-pad nudge in flight is also flushed on unmount, not lost. No missing cleanup. - **Null/empty guards carried over.** `nudgeSelected` keeps the `!canNudge`, `objectDragging`, `base` not-found, and both-selection-null early returns (`:306-322`). `HANDLE_PX` guards SSR and missing `matchMedia` with `typeof window !== 'undefined' && window.matchMedia?.(...)` (`shared.ts`). - **NudgePad render gate.** Pad is gated on `canEdit && (selectedId != null || selectedPlantingId != null)` (`:641`), and `nudgeSelected` re-checks `canNudge` from the live `nudgeCtx`, so a capability flip between render and tap can't push a mutate through a read-only state. - **Stale-closure safety.** Both call sites (the mount-once keydown effect and the pad's `onNudge`) go through `nudgeSelected`, which reads live state via `useEditorStore.getState()` and `nudgeCtx.current`; the stale `s.selectedId` captured in the debounced fire is the intended commit guard, matching pre-refactor behavior. The only behavioral shift I noticed is that `e.preventDefault()` (`:357`) now fires whenever an arrow key is pressed *and* something is selected, even if `nudgeSelected` then early-returns (e.g. `objectDragging` true or `base` missing). That suppresses arrow-scroll in those transient cases rather than erroring — harmless and arguably desirable, not a defect. </details> </details> <details><summary><b>kimi-k2.6:cloud</b> (ollama-cloud) — Blocking issues found</summary> **Verdict: Blocking issues found · ⚠️ 1/5 lens(es) errored** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** Reviewed through the security lens. The changes are strictly UI ergonomics (touch handle sizing + on-screen nudge buttons) with no introduction of untrusted-data flows, injection vectors, or authz gaps. - `HANDLE_PX` uses a hardcoded `matchMedia` query string — no injection risk. - `nudgeSelected` receives only hardcoded numeric deltas (`0`, `±1`) from the new `NudgePad` and from the existing keyboard handler’s fixed `DIRS` lookup table. No user-controlled string/input reaches the function. - The `NudgePad` renders only static text nodes and `aria-label` strings. No `dangerouslySetInnerHTML`, no interpolated user data, no DOM XSS sinks. - The `canEdit` guard is preserved before any state mutation or PATCH commit; the pad itself is conditionally rendered only when `canEdit && (selectedId != null || selectedPlantingId != null)`. - No new network requests, URL construction, or deserialization paths are introduced in this diff. </details> <details><summary><b>🎯 Correctness</b> — Blocking issues found</summary> **Blocking issues found** - **`web/src/pages/GardenEditorPage.tsx:357`** — `e.preventDefault()` is now called before `nudgeSelected` checks `canEdit` (`canNudge`) and `objectDragging`. In the original handler, `if (!canNudge) return` and `if (s.objectDragging) return` ran before any `preventDefault()`. After the refactor, when editing is disabled (past season, viewer role) or during an active pointer drag, the arrow key event is still default-prevented, but `nudgeSelected` bails out early. This swallows arrow keys with no effect, breaking page scroll and other default key behavior in read-only and drag states. **Fix:** Move the `canNudge` and `objectDragging` guards back into `onKey` before `e.preventDefault()`, or add equivalent early-return checks in `onKey` before line 357. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: agent: step 10: all chain targets failed ollama-cloud/kimi-k2.6:cloud: model returned an empty response </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> No material issues found No material performance issues found in this PR. The changes are clean from a performance perspective: - **`nudgeSelected` extraction preserves the same debounced commit behaviour** — the 400ms `setTimeout` pattern is unchanged, and the shared `commitLater` / `nudgeFire.current` / `nudgeTimer.current` refs correctly collapse a burst of nudges (held arrow key or rapid touch-pad taps) into a single PATCH. No extra network traffic is introduced. - **`HANDLE_PX` is evaluated once at module load** — no runtime re-computation or re-renders triggered by the pointer query. - **`NudgePad` is a lightweight static component** — five buttons with inline `onClick` handlers. It re-renders on selection changes anyway because of the conditional guard `selectedId != null || selectedPlantingId != null`, so the lack of `useCallback` on `nudgeSelected` or `React.memo` on `NudgePad` does not create any avoidable render churn. - **Object/planting lookups inside `nudgeSelected`** are linear (`objs.find`, `plops.find`), but these operate on garden object lists that are typically small (beds in a garden). Not a hot-path concern. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> **Minor issues** - **`web/src/editor/shared.ts:11`** — `window.matchMedia?.('(pointer: coarse)').matches` will throw a `TypeError` in environments without `matchMedia` (or if it returns `undefined`) because the optional chain only guards the call, not the `.matches` access on its result. The expression should be `window.matchMedia?.('(pointer: coarse)')?.matches` so the property read is also short-circuited. *Fix:* add a second `?.` before `.matches`. - **`web/src/pages/GardenEditorPage.tsx:357`** — `e.preventDefault()` is now called unconditionally when an arrow key is pressed and something is selected, *before* `nudgeSelected` checks `canNudge` or `objectDragging`. In read-only mode (past season or non-editor role) this swallows arrow-key page scroll / navigation without performing a nudge. During an active pointer drag it also suppresses the key without action. Previously these guards ran before `preventDefault()`, preserving default keyboard behavior when nudging was impossible. *Fix:* move `e.preventDefault()` after the viability checks, or have `nudgeSelected` return a boolean so the handler only prevents default when a nudge actually occurs. </details> </details> <details><summary><b>opencode/glm-5.2:cloud</b> (opencode) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **Verdict: No material issues found** Through the security lens, this PR is a pure UI/ergonomics change (touch handle sizing + an on-screen nudge pad), and it introduces no new trust boundary, input source, or data flow: - The `NudgePad` buttons call `onNudge` only with hardcoded integer literals (`0, -1`, `-1, 0`, etc.) — no untrusted/tainted input is routed in. - The pad delegates to the pre-existing `nudgeSelected` → `commitLater` → `uo.mutate`/`up.mutate` path, which already enforces `canEdit` (`canNudge`) and the plop-bounds clamp. No new authz bypass surface was opened; the touch path inherits the same guard the keyboard path had. - `HANDLE_PX` is a display constant derived from `window.matchMedia('(pointer: coarse)')` — no user-controllable string, no eval/template injection. - No secrets, URLs, deserialization, or server-side changes touched. I verified by reading `GardenEditorPage.tsx:240-339` (the `commitLater`/`nudgeSelected` refactor and the `canNudge` guard) and confirming the `NudgePad` only emits literal offsets. The mutate targets (`updateObject`/`updatePlanting`) are unchanged, pre-existing PATCH handlers outside this diff's scope. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> Now let me check the previous keyboard handler to compare the removed `else if` for planting. The original had separate `if/else if` blocks for object then planting. The new `nudgeSelected` mirrors this. Let me verify the Shift behavior — the original called `e.preventDefault()` only inside the branches after finding a base. The new code calls `e.preventDefault()` earlier. Let me check if this causes issues with Shift+Arrow scrolling. Looking at the original: `e.preventDefault()` was called inside each branch after `if (!base) return`. Now it's called before `nudgeSelected` regardless of whether a base exists. This is a minor behavior change but not a correctness bug. Let me look at a potential issue: the NudgePad is `md:hidden` but `nudgeSelected` doesn't take a step parameter — the pad always uses 1cm. The keyboard uses Shift for 10cm. This matches the PR intent. Let me verify the `canNudge` check — original was inside `onKey`, now in `nudgeSelected`. The NudgePad is only rendered when `canEdit`, so that's fine. But there's a subtle issue: the pad calls `nudgeSelected` directly without checking `selectedId`/`selectedPlantingId`, but `nudgeSelected` handles that internally. Good. Let me check `commitLater` — it's now a component-scope function (not in the effect). The unmount cleanup at line 362-371 still flushes `nudgeFire.current`. Since `commitLater`/`nudgeSelected` are recreated each render but use refs, the cleanup works. Good. One concern: the `commitLater` and `nudgeSelected` are recreated on every render, but the keyboard `useEffect` only mounts once (deps `[]`) and captures the *first* render's `nudgeSelected` closure. Let me verify this is a problem. The `useEffect` has `// eslint-disable-next-line react-hooks/exhaustive-deps` and deps `[]`, so `onKey` captures the `nudgeSelected` from the first render. But `nudgeSelected` only reads from `nudgeCtx.current` (a ref updated every render) and `useEditorStore.getState()` (live). So even the stale closure reads live data. This is the same pattern as before — the original `onKey` also captured first-render values but used refs/store for live data. So no regression. Now the NudgePad gets `onNudge={nudgeSelected}` from the current render, which is fine since it re-renders. Let me double check the plop clamp. Original: `nx = base.xCm + dir[0]*step`, clamped to `[-widthCm/2, widthCm/2]`. New: `nx = base.xCm + dx`, same clamp. Identical. Good. Now `HANDLE_PX` — let me verify it's used consistently and that the touch detection is sound. The `HANDLE_PX` is read once at module load. This matches the PR description. The touch detection via `matchMedia('(pointer: coarse)')` is a reasonable approach. Hybrids (touch laptop) would get 22px which is slightly large for mouse but acceptable. Not a correctness bug. Let me verify one more thing — the original `onKey` had the input-element guard returning early BEFORE the `selectedId`/`selectedPlantingId` check. New code preserves that order. Good. And the new code added `if (s.selectedId == null && s.selectedPlantingId == null) return` before `e.preventDefault()`. This means if nothing is selected, no preventDefault — correct, doesn't break arrow scrolling when nothing selected. Good. One thing to verify: the original called `e.preventDefault()` *after* the `if (!base) return` checks. So if base wasn't found (e.g., selected id doesn't match any object — a transient stale state), it would NOT preventDefault. The new code calls `e.preventDefault()` *before* `nudgeSelected`, so even if `base` isn't found inside `nudgeSelected`, preventDefault already fired. This is a minor behavior change: arrow keys would be swallowed during a brief stale-selection window. Let me assess impact — `base` not found means the selection points to a non-existent object, which is transient. Preventing arrow scroll briefly is negligible. Not a real bug. Let me check the NudgePad z-index / overlap with anything important, but that's UX not correctness. Let me verify the `selectedId != null || selectedPlantingId != null` rendering condition matches what `nudgeSelected` handles — yes, it handles both branches. The change looks correct. Let me do a final check on whether there's a stale-closure issue with `nudgeSelected` being passed to `NudgePad` — since `NudgePad` re-renders when `selectedId`/`selectedPlantingId` change (those are reactive in the parent), it gets a fresh `nudgeSelected` each time. Fine. Let me verify the `commitLater`/`nudgeSelected` aren't wrapped in `useCallback` and whether that causes the `NudgePad` to remount unnecessarily — not a correctness issue. I've verified the logic thoroughly. The refactor faithfully preserves the original nudge semantics, the touch pad uses the same path, and the `HANDLE_PX` change is sound. ## VERDICT: No material issues found The refactor correctly extracts `nudgeSelected(dx, dy)` and `commitLater` from the keyboard handler. I verified: - **Plop clamp math** (`GardenEditorPage.tsx:327-328`) is identical to the original: `nx = base.xCm + dx` clamped to `[-widthCm/2, widthCm/2]`. The unit is cm throughout, no scale confusion. - **Debounce + commit-on-unmount** (`GardenEditorPage.tsx:292-301, 362-371`): `commitLater` now lives at component scope but uses the same `nudgeFire`/`nudgeTimer` refs, and the effect's cleanup still flushes a pending commit. Correct — refs are stable across renders. - **Stale-closure safety**: the keyboard `useEffect` mounts once (`deps: []`) and captures the first-render `nudgeSelected`, but that closure only reads live data via `nudgeCtx.current` (a ref reassigned every render at line 144) and `useEditorStore.getState()`. Same pattern as the pre-refactor handler; no regression. - **`canNudge` guard** moved into `nudgeSelected` (`:306`); the pad is only rendered when `canEdit` (`:641`), and the keyboard path no longer re-checks it but `nudgeSelected` does — correct. - **`e.preventDefault()` ordering** (`:357`): now fires before `nudgeSelected` rather than after the `if (!base) return` checks. This means arrow scroll is suppressed during a transient stale-selection window where `base` can't be resolved. Negligible — that window is a render-tick where the selected id points at a vanished object, and suppressing scroll there isn't a real bug. - **`HANDLE_PX`** (`shared.ts:10`): `matchMedia('(pointer: coarse)')` read once at module load, used as `HANDLE_PX / scale` in both overlays. Sound; a hybrid touch-laptop gets 22px which is usable for both. No unit or threshold issue. - **NudgePad step**: always 1cm (no Shift modifier on touch), matching the PR's stated intent; the keyboard path still applies `shiftKey ? 10 : 1`. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Verdict: Minor issues The refactor is clean and the extraction is well-motivated. I verified `GardenEditorPage.tsx` (lines 286-373, 839-872) and `shared.ts` directly. All three notes below are confirmed against the actual code and are low-impact cleanliness observations: - `web/src/pages/GardenEditorPage.tsx:847-870` — `NudgePad` lays out its 3×3 grid with four empty `<span />` spacer cells plus a center label. It works, but a reader must count cells to map arrows to positions. `grid-template-areas` (e.g. `". up ." "left mid right" ". down ."`) would make the d-pad layout self-documenting and drop the dead spacer elements. Trivial; the current form is readable enough. - `web/src/pages/GardenEditorPage.tsx:845,861` — the `40px` target size (`size-10`) lives in two places: the `btn` class string (line 845) and again on the center `"1cm"` label span (line 861). If the touch target ever needs tuning, both must move. A shared class or applying `btn` to the label too would keep them in sync. Trivial duplication. - `web/src/pages/GardenEditorPage.tsx:303` — `nudgeSelected` and `commitLater` are defined in the component body (recreated per render) but the keyboard `useEffect` (line 343, empty deps at line 373) captures the *first render's* copies. This is functionally correct today because every read goes through `nudgeCtx.current` / `useEditorStore.getState()`, and it matches the pre-existing mount-once philosophy. The new asymmetry introduced by lifting these out of the effect: the touch pad gets a fresh closure each render while the keyboard uses a stale one. A future edit that reads a plain closure variable (say, `canEdit` directly instead of `nudgeCtx.current.canEdit`) inside `nudgeSelected` would silently break the keyboard path while the pad keeps working — and nothing in the code flags that trap. Worth a one-line comment on `nudgeSelected` noting "must read live values via refs/getState; the keyboard effect captures this once." Not blocking. Nothing here is material enough to block; the extraction is a genuine improvement over the prior duplicated branch logic. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> I'll review this through the Performance lens. Let me verify the key code paths. I've verified the code paths through the lens of performance. The refactor preserves the existing debounced single-PATCH behavior (`commitLater` clears the prior timer before setting a new one), and both the keyboard and touch surfaces funnel through `nudgeSelected`, so a burst of taps commits once. No new allocations, loops, or unbounded growth introduced. **VERDICT: No material issues found** - The `objs.find` / `plops.find` lookups in `nudgeSelected` are O(n) over the objects/plantings arrays, but these are inherited from the pre-existing keyboard handler (the diff just moved them), and the arrays are small (per-garden object/plop counts). Not a regression. - `NudgePad` is `md:hidden` and gated on a selection existing, so it only mounts on touch layouts when relevant; it's four buttons + five spans, trivial render cost. Tapping it triggers a store update that re-renders the parent, but the parent already re-renders on every store change, and `NudgePad` is a leaf with no children to reconcile. - `nudgeSelected`/`commitLater` are recreated each render (not memoized), but they're only consumed by a once-mounted keyboard effect via a closure that reads live state through `nudgeCtx.current` / `useEditorStore.getState()` — stale closure is intentional and correct, no per-render work leaks into hot paths. - Timer discipline is intact: `clearTimeout` on reschedule, and the unmount cleanup flushes the pending commit. No orphaned timers or leaked work. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> **VERDICT: Minor issues** - **`NudgePad` flush-on-unmount gap (`GardenEditorPage.tsx:641`).** Verified: `nudgeSelected` (line 303) debounces the PATCH through `commitLater` (400ms timer in `nudgeTimer.current`), and the *only* place a pending nudge is flushed on teardown is the keyboard `useEffect`'s cleanup (lines 362–371). That effect is mounted once for the page's lifetime, so a flush does run on unmount — fine. But there's a subtle edge: if the user nudges via the **pad** and then a data refetch (or a `mutate`'s `onSuccess`) clears the selection and unmounts/remounts the keyboard effect, the cleanup clears `nudgeTimer.current` and fires the pending `fn`, which calls `useEditorStore.getState().liveObject`/`livePlanting` and checks `live?.id !== s.selectedId`. That guard is evaluated against the *selection at fire time*, not at nudge time — and `selectedId` is captured by closure inside the `fire` from `nudgeSelected`. Actually re-reading: `s.selectedId` inside `commitLater`'s `fire` references the outer `s` captured at the `nudgeSelected` call. Let me verify this is the same `s`. Let me verify the closure capture precisely. **VERDICT: No material issues found** Verified against the checked-out code: - `nudgeSelected` (lines 303–338): all the unhappy paths are guarded. `canNudge` false → returns. `objectDragging` active → returns. `base` not found in either `liveObject`/`livePlanting` or the objects/plantings arrays → returns. Neither `selectedId` nor `selectedPlantingId` set → falls through with no-op. The debounced `fire` callbacks re-read live state and bail (`live?.id !== s.selectedId` / `selectedPlantingId`) if a drag cleared the live value and already committed — that guard is intact post-refactor (lines 315, 333). - `commitLater` (lines 292–301): clears any in-flight timer before setting a new one, so a burst of taps doesn't stack timers; on fire it nulls `nudgeTimer.current` and `nudgeFire.current` before invoking `fn?.()` (the `?.` covers a null fn). - Keyboard `useEffect` cleanup (lines 362–371): flushes a pending nudge on unmount and clears the timer — no lost commit, no dangling timer firing after unmount. This cleanup runs regardless of which surface (pad or keyboard) scheduled the nudge, since `nudgeTimer`/`nudgeFire` are component-level refs shared by both call sites. - `HANDLE_PX` (`shared.ts:8`): guarded with `typeof window !== 'undefined'` and `window.matchMedia?.` optional-chaining — no crash in SSR/`undefined` window; falls back to 12px. No NaN/negative path. - `NudgePad` (lines 839–870): pure presentational; `onNudge` invoked with fixed `(0,-1)` etc. No edge case on empty input; the pad only renders when a selection exists (line 640 guard), and `nudgeSelected` independently re-checks selection from store state, so a stale render (selection cleared between render and tap) is handled by the `selectedId == null` fall-through. The plop clamp (lines 327–328) uses `obj.widthCm/2` and `obj.heightCm/2`; if `obj` is absent the clamp is skipped (line 326 `if (obj)`), so no NaN from a missing parent object. No integer-overflow or boundary concerns at play (values are small REAL cm quantities). No swallowed errors: `uo.mutate`/`up.mutate` rely on the existing mutation error surface (TanStack Query), unchanged by this PR. </details> </details> <details><summary><b>opencode/kimi-k2.6:cloud</b> (opencode) — No material issues found</summary> **Verdict: No material issues found · ⚠️ 2/5 lens(es) errored** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** Reviewed the touch-ergonomics changes through the security lens. The PR is a pure frontend refactor plus a new `NudgePad` UI component. - `HANDLE_PX` media-query fallback is safe (`typeof window !== 'undefined'` guards SSR). - `NudgePad` renders hardcoded Unicode arrows and Tailwind classes — no user-controlled data reaches JSX, `className`, or DOM. - `nudgeSelected` receives bounded deltas (±1 or ±10 cm) from the keyboard map or hardcoded button handlers; there is no untrusted input path to `dx`/`dy`. - The existing `canEdit` authorization check is preserved inside `nudgeSelected` before any live-geometry update or mutation is triggered. - No new secrets, credentials, eval-like usage, or injection surfaces are introduced. Nothing in this change warrants a security finding. </details> <details><summary><b>🎯 Correctness</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: opencode run returned no output </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary> No material issues found. - The `nudgeSelected` / `commitLater` extraction removes duplication between keyboard and touch surfaces cleanly, without pulling in surface-specific logic (Shift stepping stays in the keyboard handler, the pad is its own component). Verified by reading the full function at `GardenEditorPage.tsx:303` and its callers at `:359` and `:642`. - `NudgePad` is a small, focused, presentational component with proper ARIA attributes (`role="group"`, per-button `aria-label`). No logic leakage. - `HANDLE_PX` uses a single, readable conditional with an accurate inline rationale; no drift from surrounding patterns in `shared.ts`. - The guard in the JSX (`canEdit && (selectedId != null || selectedPlantingId != null)`) is appropriately placed and doesn't fight the inner `canNudge` check inside `nudgeSelected`. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **No material issues found** Through the **Performance** lens I checked: - **Debouncing**: The shared `commitLater` mechanism still cancels the previous timer and re-schedules a 400 ms flush, so a burst of keyboard arrows or touch-pad taps results in one PATCH. Verified by reading the refactored `nudgeSelected` and `commitLater` at `web/src/pages/GardenEditorPage.tsx:292–301` and `:303–338`. - **Shared timer/refs**: Both the keyboard effect and the new `NudgePad` read/write the same `nudgeTimer`/`nudgeFire` refs, so cross-surface nudging properly collapses into a single commit. Confirmed by tracing refs declared at `:110–111`. - **Live-update cost**: Each nudge calls `setLiveObject`/`setLivePlanting` for instant SVG feedback (same as before the refactor). The canvas already subscribed to these; no new hot-path subscriptions were added. - **NudgePad render cost**: It’s a 5-button, absolutely-positioned div with no heavy computations or unmemoized large lists. Conditional render is gated on selection state (`selectedId != null || selectedPlantingId != null`), so it’s absent when not needed. - **HANDLE_PX evaluation**: Evaluated once at module load in `web/src/editor/shared.ts:10–11`; no repeated `matchMedia` calls. Nothing here introduces an N+1, unbounded growth, layout thrash, or work inside a hot loop that wasn’t already present. </details> <details><summary><b>🧯 Error handling & edge cases</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: opencode run context deadline exceeded </details> </details> </details> <sub>Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.</sub>
Sign in to join this conversation.