Address touch review: one coarse-pointer signal + stable nudge callbacks
Build image / build-and-push (push) Successful in 10s

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
This commit is contained in:
2026-07-22 10:19:53 -04:00
co-authored by Claude Opus 4.8
parent 1323a03acd
commit b0e11bce17
2 changed files with 30 additions and 19 deletions
+11 -6
View File
@@ -3,12 +3,17 @@
// one place instead of drifting between files. // one place instead of drifting between files.
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
// On-screen size of a drag/resize handle. Bigger on a coarse pointer (touch) so // Whether the primary pointer is a fingertip rather than a mouse — the one signal
// a fingertip can actually grab a resize corner or the rotate knob — 12px is fine // the touch affordances key off (bigger handles here, the on-screen nudge pad in
// for a mouse but frustrating for a thumb (#104). Read once at load; a device // the editor), so they can't disagree about what "touch" means. Read once at
// doesn't switch its primary pointer mid-session. // load; a device doesn't switch its primary pointer mid-session, and the optional
export const HANDLE_PX = // chain keeps it false (mouse defaults) under test / SSR where matchMedia is absent.
typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)').matches ? 22 : 12 export const isCoarsePointer =
typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches
// On-screen size of a drag/resize handle. Bigger on 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).
export const HANDLE_PX = isCoarsePointer ? 22 : 12
export const MIN_RADIUS_CM = 1 // smallest plop radius export const MIN_RADIUS_CM = 1 // smallest plop radius
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
+19 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert' import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -18,6 +18,7 @@ import { EditorHint } from '@/editor/EditorHint'
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker' import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
import { objectDisplayName } from '@/editor/kinds' import { objectDisplayName } from '@/editor/kinds'
import { useEditorStore, type EditorMode } from '@/editor/store' import { useEditorStore, type EditorMode } from '@/editor/store'
import { isCoarsePointer } from '@/editor/shared'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import type { EditorGarden } from '@/editor/types' import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal' import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
@@ -289,7 +290,11 @@ export function GardenEditorPage() {
// object's local bounds. Reads live values from getState/nudgeCtx so it's // 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 // 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). // still present (a drag that cleared it already committed its own PATCH).
const commitLater = (fire: () => void) => { // Stable across renders (empty deps): both read live values through refs
// (nudgeCtx) / getState, never through closed-over props, so a mount-once
// consumer (the keydown effect) and a memo-friendly one (NudgePad) both get a
// function that stays current without a new identity each render.
const commitLater = useCallback((fire: () => void) => {
nudgeFire.current = fire nudgeFire.current = fire
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current) if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
nudgeTimer.current = window.setTimeout(() => { nudgeTimer.current = window.setTimeout(() => {
@@ -298,9 +303,9 @@ export function GardenEditorPage() {
nudgeFire.current = null nudgeFire.current = null
fn?.() fn?.()
}, 400) }, 400)
} }, [])
const nudgeSelected = (dx: number, dy: number) => { const nudgeSelected = useCallback((dx: number, dy: number) => {
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } = const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
nudgeCtx.current nudgeCtx.current
if (!canNudge) return if (!canNudge) return
@@ -335,7 +340,7 @@ export function GardenEditorPage() {
useEditorStore.getState().setLivePlanting(null) useEditorStore.getState().setLivePlanting(null)
}) })
} }
} }, [commitLater])
// Keyboard nudging (desktop): arrows move the selection 1cm, Shift = 10cm — the // Keyboard nudging (desktop): arrows move the selection 1cm, Shift = 10cm — the
// same nudgeSelected the touch pad uses. Mounted once; a pending commit is // same nudgeSelected the touch pad uses. Mounted once; a pending commit is
@@ -636,9 +641,10 @@ export function GardenEditorPage() {
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} /> <GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
{/* Touch fine-positioning: the keyboard's arrow-nudge has no equivalent {/* Touch fine-positioning: the keyboard's arrow-nudge has no equivalent
on a phone, and dragging can't hit single-cm precision. Shown while on a touch device, and dragging can't hit single-cm precision. Shown
something's selected on a touch layout (#104). */} on a coarse pointer (same signal as the bigger handles) while
{canEdit && (selectedId != null || selectedPlantingId != null) && ( something's selected (#104). */}
{canEdit && isCoarsePointer && (selectedId != null || selectedPlantingId != null) && (
<NudgePad onNudge={nudgeSelected} /> <NudgePad onNudge={nudgeSelected} />
)} )}
</div> </div>
@@ -836,10 +842,10 @@ function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; b
) )
} }
// On-screen nudge pad (#104): 1cm arrows for the selected object/plop on touch, // On-screen nudge pad (#104): 1cm arrows for the selected object/plop on a touch
// where the keyboard's arrow-nudge isn't reachable and a drag can't hit single-cm // device, where the keyboard's arrow-nudge isn't reachable and a drag can't hit
// precision. md:hidden — desktop uses the keyboard. Wired to the same // single-cm precision. Rendered only on a coarse pointer (the caller gates it).
// nudgeSelected, so it shares the live-then-debounced-PATCH behaviour. // Wired to the same nudgeSelected, so it shares the live-then-debounced-PATCH.
function NudgePad({ onNudge }: { onNudge: (dx: number, dy: number) => void }) { function NudgePad({ onNudge }: { onNudge: (dx: number, dy: number) => void }) {
const btn = const btn =
'flex size-10 items-center justify-center rounded-md border border-border bg-surface/90 text-fg ' + 'flex size-10 items-center justify-center rounded-md border border-border bg-surface/90 text-fg ' +
@@ -848,7 +854,7 @@ function NudgePad({ onNudge }: { onNudge: (dx: number, dy: number) => void }) {
<div <div
role="group" role="group"
aria-label="Nudge selection by 1cm" 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" className="absolute bottom-2 left-2 z-20 grid grid-cols-3 grid-rows-3 gap-0.5"
> >
<span /> <span />
<button type="button" className={btn} aria-label="Nudge up" onClick={() => onNudge(0, -1)}> <button type="button" className={btn} aria-label="Nudge up" onClick={() => onNudge(0, -1)}>