Address review: disarm on tray-remove, cap load, tidy
Build image / build-and-push (push) Successful in 9s

- Removing the armed plant from the tray now disarms it, so placement
  doesn't continue for a plant that's no longer shown.
- loadIds honors TRAY_MAX even if localStorage was hand-edited.
- Consolidate the repeated `canEdit && plantable` toolbar guards into one
  nested conditional (3-model finding).
- Rename the hook's SeedTray interface to SeedTrayState (avoids colliding
  with the SeedTray component) and the component prop plants → trayPlants.
- Hoist ensurePlant to one call in onPickPlant; pass onPickPlant directly as
  onSelect; use ASCII "+" for the tray chip; document the FIFO eviction.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-19 02:08:53 -04:00
co-authored by Claude Opus 4.8
parent 75cdac2925
commit 30e8d3e56c
3 changed files with 52 additions and 38 deletions
+6 -6
View File
@@ -5,17 +5,17 @@ import type { Plant } from '@/lib/plants'
/** /**
* The Seed Tray strip in the focus-mode toolbar: a row of the garden's * The Seed Tray strip in the focus-mode toolbar: a row of the garden's
* kept-at-hand plants. Tap a chip to arm that plant for tap-to-place (the armed * kept-at-hand plants. Tap a chip to arm that plant for tap-to-place (the armed
* chip is highlighted); ✕ removes it from the tray; the trailing chip opens * chip is highlighted); ✕ removes it from the tray; the trailing "+ Plant" chip
* the full catalog picker. See useSeedTray for persistence. * opens the full catalog picker. See useSeedTray for persistence.
*/ */
export function SeedTray({ export function SeedTray({
plants, trayPlants,
armedPlantId, armedPlantId,
onArm, onArm,
onRemove, onRemove,
onOpenPicker, onOpenPicker,
}: { }: {
plants: Plant[] trayPlants: Plant[]
armedPlantId: number | null armedPlantId: number | null
onArm: (plant: Plant) => void onArm: (plant: Plant) => void
onRemove: (id: number) => void onRemove: (id: number) => void
@@ -23,7 +23,7 @@ export function SeedTray({
}) { }) {
return ( return (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{plants.map((p) => { {trayPlants.map((p) => {
const active = p.id === armedPlantId const active = p.id === armedPlantId
return ( return (
<span <span
@@ -59,7 +59,7 @@ export function SeedTray({
onClick={onOpenPicker} onClick={onOpenPicker}
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2.5 py-1 text-xs font-medium text-muted outline-none transition-colors hover:border-accent hover:text-accent-strong focus-visible:ring-2 focus-visible:ring-accent/40" className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2.5 py-1 text-xs font-medium text-muted outline-none transition-colors hover:border-accent hover:text-accent-strong focus-visible:ring-2 focus-visible:ring-accent/40"
> >
Plant + Plant
</button> </button>
</div> </div>
) )
+6 -4
View File
@@ -16,7 +16,8 @@ const TRAY_MAX = 24 // a working set, not a catalog; caps pathological growth
function loadIds(gardenId: number): number[] { function loadIds(gardenId: number): number[] {
try { try {
const v: unknown = JSON.parse(localStorage.getItem(KEY(gardenId)) ?? '[]') const v: unknown = JSON.parse(localStorage.getItem(KEY(gardenId)) ?? '[]')
return Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : [] const ids = Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
return ids.slice(-TRAY_MAX) // honor the cap even if storage was hand-edited
} catch { } catch {
return [] return []
} }
@@ -30,17 +31,18 @@ function saveIds(gardenId: number, ids: number[]) {
} }
} }
export interface SeedTray { export interface SeedTrayState {
/** Tray plants in insertion order, resolved against the live catalog. */ /** Tray plants in insertion order, resolved against the live catalog. */
trayPlants: Plant[] trayPlants: Plant[]
/** Add a plant to the end of the tray (no-op if already present). */ /** Add a plant to the end of the tray (no-op if already present; when the tray
* is full the oldest entry is evicted). */
add: (plant: Plant) => void add: (plant: Plant) => void
/** Remove a plant from the tray. */ /** Remove a plant from the tray. */
remove: (id: number) => void remove: (id: number) => void
} }
/** Per-garden Seed Tray backed by localStorage and the plant catalog. */ /** Per-garden Seed Tray backed by localStorage and the plant catalog. */
export function useSeedTray(gardenId: number): SeedTray { export function useSeedTray(gardenId: number): SeedTrayState {
const plants = usePlants() const plants = usePlants()
const [ids, setIds] = useState<number[]>(() => loadIds(gardenId)) const [ids, setIds] = useState<number[]>(() => loadIds(gardenId))
+23 -11
View File
@@ -244,18 +244,26 @@ export function GardenEditorPage() {
setArmedPlant(plant) setArmedPlant(plant)
} }
// Removing the armed plant from the tray also disarms it, so placement doesn't
// silently continue for a plant the user just cleared out.
function removeFromTrayAndDisarm(id: number) {
removeFromTray(id)
if (armedPlant?.id === id) setArmedPlant(null)
}
// The picker hands back the full Plant (from the whole catalog), so use it // The picker hands back the full Plant (from the whole catalog), so use it
// directly rather than re-resolving against the garden's referenced-plant map — // directly rather than re-resolving against the garden's referenced-plant map —
// a not-yet-placed plant isn't in that map, which used to silently abort the // a not-yet-placed plant isn't in that map, which used to silently abort the
// pick (nothing armed, picker left open). // pick (nothing armed, picker left open). Picking (place OR change) makes sure
// the plant renders and drops it into this garden's tray for quick reuse.
function onPickPlant(plant: Plant) { function onPickPlant(plant: Plant) {
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
ensurePlant(plant)
addToTray(plant) addToTray(plant)
if (picker === 'change' && selectedPlop) { if (picker === 'change' && selectedPlop) {
ensurePlant(plant)
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id }) updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
} else { } else {
armPlant(plant) // 'place' mode: arm for repeat placement setArmedPlant(plant) // 'place' mode: arm for repeat placement
} }
setPicker(null) setPicker(null)
} }
@@ -284,22 +292,22 @@ export function GardenEditorPage() {
{garden.name} {garden.name}
</button> </button>
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span> <span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
{canEdit && !focusedObject.plantable && <span className="text-xs text-muted">Not plantable</span>} {canEdit &&
{canEdit && focusedObject.plantable && ( (focusedObject.plantable ? (
<>
<SeedTray <SeedTray
plants={trayPlants} trayPlants={trayPlants}
armedPlantId={armedPlant?.id ?? null} armedPlantId={armedPlant?.id ?? null}
onArm={armPlant} onArm={armPlant}
onRemove={removeFromTray} onRemove={removeFromTrayAndDisarm}
onOpenPicker={() => setPicker('place')} onOpenPicker={() => setPicker('place')}
/> />
)} {armedPlant && (
{canEdit && focusedObject.plantable && armedPlant && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}> <Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Done Done
</Button> </Button>
)} )}
{canEdit && focusedObject.plantable && focusedPlops.length > 0 && ( {focusedPlops.length > 0 && (
<Button <Button
variant="ghost" variant="ghost"
className="px-2 py-1 text-xs text-red-600 dark:text-red-400" className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
@@ -308,6 +316,10 @@ export function GardenEditorPage() {
Clear ({focusedPlops.length}) Clear ({focusedPlops.length})
</Button> </Button>
)} )}
</>
) : (
<span className="text-xs text-muted">Not plantable</span>
))}
</div> </div>
)} )}
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} /> <GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
@@ -355,7 +367,7 @@ export function GardenEditorPage() {
<PlantPicker <PlantPicker
unit={garden.unitPref} unit={garden.unitPref}
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
onSelect={(p) => onPickPlant(p)} onSelect={onPickPlant}
/> />
)} )}