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

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
This commit is contained in:
2026-07-22 10:13:23 -04:00
co-authored by Claude Opus 4.8
parent 06b887f58e
commit 1323a03acd
2 changed files with 107 additions and 53 deletions
+6 -1
View File
@@ -3,7 +3,12 @@
// one place instead of drifting between files.
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
export const HANDLE_PX = 12 // on-screen size of a drag/resize handle
// On-screen size of a drag/resize handle. Bigger on a coarse pointer (touch) so
// a fingertip can actually grab a resize corner or the rotate knob — 12px is fine
// for a mouse but frustrating for a thumb (#104). Read once at load; a device
// doesn't switch its primary pointer mid-session.
export const HANDLE_PX =
typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)').matches ? 22 : 12
export const MIN_RADIUS_CM = 1 // smallest plop radius
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
+101 -52
View File
@@ -283,13 +283,63 @@ export function GardenEditorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Desktop keyboard nudging: arrows move the selected object/plop 1cm (Shift =
// 10cm); the PATCH is debounced ~400ms on key-idle so a held key doesn't spam.
// Plops nudge in their object's local frame and clamp to its (local) bounds.
// Mounted once, reading live values from nudgeCtx so a data refetch can't
// re-subscribe and cancel a pending commit; the pending commit is flushed on
// unmount, and a fire only commits if its live value is still present (a drag
// that cleared it already committed its own PATCH).
// Move the selected object/plop by (dx, dy) cm: apply live geometry instantly,
// then commit ONE debounced PATCH so a burst of nudges (a held arrow key, or
// repeated taps of the touch pad) doesn't spam the server. Plops clamp to their
// object's local bounds. Reads live values from getState/nudgeCtx so it's
// correct whichever surface calls it; a commit only fires if its live value is
// still present (a drag that cleared it already committed its own PATCH).
const commitLater = (fire: () => void) => {
nudgeFire.current = fire
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
nudgeTimer.current = window.setTimeout(() => {
nudgeTimer.current = null
const fn = nudgeFire.current
nudgeFire.current = null
fn?.()
}, 400)
}
const nudgeSelected = (dx: number, dy: number) => {
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
nudgeCtx.current
if (!canNudge) return
const s = useEditorStore.getState()
if (s.objectDragging) return // don't fight an active pointer drag
if (s.selectedId != null) {
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
if (!base) return
s.setLiveObject({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy })
commitLater(() => {
const live = useEditorStore.getState().liveObject
if (live?.id !== s.selectedId) return
uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
useEditorStore.getState().setLiveObject(null)
})
} else if (s.selectedPlantingId != null) {
const base =
s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId)
if (!base) return
const obj = objs.find((o) => o.id === base.objectId)
let nx = base.xCm + dx
let ny = base.yCm + dy
if (obj) {
nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx))
ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny))
}
s.setLivePlanting({ ...base, xCm: nx, yCm: ny })
commitLater(() => {
const live = useEditorStore.getState().livePlanting
if (live?.id !== s.selectedPlantingId) return
up.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
useEditorStore.getState().setLivePlanting(null)
})
}
}
// Keyboard nudging (desktop): arrows move the selection 1cm, Shift = 10cm — the
// same nudgeSelected the touch pad uses. Mounted once; a pending commit is
// flushed on unmount so a nudge in flight isn't lost.
useEffect(() => {
const DIRS: Record<string, [number, number]> = {
ArrowUp: [0, -1],
@@ -297,58 +347,16 @@ export function GardenEditorPage() {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
}
const commitLater = (fire: () => void) => {
nudgeFire.current = fire
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
nudgeTimer.current = window.setTimeout(() => {
nudgeTimer.current = null
const fn = nudgeFire.current
nudgeFire.current = null
fn?.()
}, 400)
}
function onKey(e: KeyboardEvent) {
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
nudgeCtx.current
if (!canNudge) return
const dir = DIRS[e.key]
if (!dir) return
const el = document.activeElement
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
const s = useEditorStore.getState()
if (s.objectDragging) return // don't fight an active pointer drag
if (s.selectedId == null && s.selectedPlantingId == null) return
e.preventDefault()
const step = e.shiftKey ? 10 : 1
if (s.selectedId != null) {
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
if (!base) return
e.preventDefault()
s.setLiveObject({ ...base, xCm: base.xCm + dir[0] * step, yCm: base.yCm + dir[1] * step })
commitLater(() => {
const live = useEditorStore.getState().liveObject
if (live?.id !== s.selectedId) return // a drag cleared it and committed
uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
useEditorStore.getState().setLiveObject(null)
})
} else if (s.selectedPlantingId != null) {
const base =
s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId)
if (!base) return
e.preventDefault()
const obj = objs.find((o) => o.id === base.objectId)
let nx = base.xCm + dir[0] * step
let ny = base.yCm + dir[1] * step
if (obj) {
nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx))
ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny))
}
s.setLivePlanting({ ...base, xCm: nx, yCm: ny })
commitLater(() => {
const live = useEditorStore.getState().livePlanting
if (live?.id !== s.selectedPlantingId) return
up.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
useEditorStore.getState().setLivePlanting(null)
})
}
nudgeSelected(dir[0] * step, dir[1] * step)
}
window.addEventListener('keydown', onKey)
return () => {
@@ -627,6 +635,12 @@ export function GardenEditorPage() {
)}
<div className="relative min-h-0 flex-1">
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
{/* Touch fine-positioning: the keyboard's arrow-nudge has no equivalent
on a phone, and dragging can't hit single-cm precision. Shown while
something's selected on a touch layout (#104). */}
{canEdit && (selectedId != null || selectedPlantingId != null) && (
<NudgePad onNudge={nudgeSelected} />
)}
</div>
{/* Empty-state hints (non-interactive overlays). */}
@@ -822,6 +836,41 @@ function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; b
)
}
// On-screen nudge pad (#104): 1cm arrows for the selected object/plop on touch,
// where the keyboard's arrow-nudge isn't reachable and a drag can't hit single-cm
// precision. md:hidden — desktop uses the keyboard. Wired to the same
// nudgeSelected, so it shares the live-then-debounced-PATCH behaviour.
function NudgePad({ onNudge }: { onNudge: (dx: number, dy: number) => void }) {
const btn =
'flex size-10 items-center justify-center rounded-md border border-border bg-surface/90 text-fg ' +
'shadow-sm outline-none backdrop-blur transition-colors active:bg-border/70 focus-visible:ring-2 focus-visible:ring-accent/40'
return (
<div
role="group"
aria-label="Nudge selection by 1cm"
className="absolute bottom-2 left-2 z-20 grid grid-cols-3 grid-rows-3 gap-0.5 md:hidden"
>
<span />
<button type="button" className={btn} aria-label="Nudge up" onClick={() => onNudge(0, -1)}>
</button>
<span />
<button type="button" className={btn} aria-label="Nudge left" onClick={() => onNudge(-1, 0)}>
</button>
<span className="flex size-10 items-center justify-center text-[0.6rem] font-medium text-muted">1cm</span>
<button type="button" className={btn} aria-label="Nudge right" onClick={() => onNudge(1, 0)}>
</button>
<span />
<button type="button" className={btn} aria-label="Nudge down" onClick={() => onNudge(0, 1)}>
</button>
<span />
</div>
)
}
// The mobile primary mode switch (#99): one always-there tab bar so "placing
// beds", "planting", "journaling" and "assistant" stop competing for the same
// strip. Assistant is dropped when the instance has no model configured.