From 1323a03acd32e3ba92c10477a3ad0d846a1985d8 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 10:13:23 -0400 Subject: [PATCH 1/2] Touch ergonomics: bigger handles + on-screen nudge pad (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- web/src/editor/shared.ts | 7 +- web/src/pages/GardenEditorPage.tsx | 153 +++++++++++++++++++---------- 2 files changed, 107 insertions(+), 53 deletions(-) diff --git a/web/src/editor/shared.ts b/web/src/editor/shared.ts index 21dd422..5fd68e3 100644 --- a/web/src/editor/shared.ts +++ b/web/src/editor/shared.ts @@ -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 diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index 6d0be53..a3fa0b8 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -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 = { 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() { )}
+ {/* 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) && ( + + )}
{/* 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 ( +
+ + + + + 1cm + + + + +
+ ) +} + // 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. From b0e11bce17cfd20581cc7efc752ef38ffa275986 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 10:19:53 -0400 Subject: [PATCH 2/2] Address touch review: one coarse-pointer signal + stable nudge callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- web/src/editor/shared.ts | 17 ++++++++++------ web/src/pages/GardenEditorPage.tsx | 32 ++++++++++++++++++------------ 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/web/src/editor/shared.ts b/web/src/editor/shared.ts index 5fd68e3..5ac4e64 100644 --- a/web/src/editor/shared.ts +++ b/web/src/editor/shared.ts @@ -3,12 +3,17 @@ // one place instead of drifting between files. export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles -// 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 +// Whether the primary pointer is a fingertip rather than a mouse — the one signal +// 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. +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 DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index a3fa0b8..1457f5d 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -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 { Alert } from '@/components/ui/Alert' import { Button } from '@/components/ui/Button' @@ -18,6 +18,7 @@ import { EditorHint } from '@/editor/EditorHint' import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker' import { objectDisplayName } from '@/editor/kinds' import { useEditorStore, type EditorMode } from '@/editor/store' +import { isCoarsePointer } from '@/editor/shared' import { cn } from '@/lib/cn' import type { EditorGarden } from '@/editor/types' 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 // 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) => { + // 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 if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current) nudgeTimer.current = window.setTimeout(() => { @@ -298,9 +303,9 @@ export function GardenEditorPage() { nudgeFire.current = null fn?.() }, 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 } = nudgeCtx.current if (!canNudge) return @@ -335,7 +340,7 @@ export function GardenEditorPage() { useEditorStore.getState().setLivePlanting(null) }) } - } + }, [commitLater]) // Keyboard nudging (desktop): arrows move the selection 1cm, Shift = 10cm — the // same nudgeSelected the touch pad uses. Mounted once; a pending commit is @@ -636,9 +641,10 @@ export function GardenEditorPage() {
{/* 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) && ( + on a touch device, and dragging can't hit single-cm precision. Shown + on a coarse pointer (same signal as the bigger handles) while + something's selected (#104). */} + {canEdit && isCoarsePointer && (selectedId != null || selectedPlantingId != null) && ( )}
@@ -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, -// 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. +// On-screen nudge pad (#104): 1cm arrows for the selected object/plop on a touch +// device, where the keyboard's arrow-nudge isn't reachable and a drag can't hit +// single-cm precision. Rendered only on a coarse pointer (the caller gates it). +// Wired to the same nudgeSelected, so it shares the live-then-debounced-PATCH. 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 ' + @@ -848,7 +854,7 @@ function NudgePad({ onNudge }: { onNudge: (dx: number, dy: number) => void }) {