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
* 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
* the full catalog picker. See useSeedTray for persistence.
* chip is highlighted); ✕ removes it from the tray; the trailing "+ Plant" chip
* opens the full catalog picker. See useSeedTray for persistence.
*/
export function SeedTray({
plants,
trayPlants,
armedPlantId,
onArm,
onRemove,
onOpenPicker,
}: {
plants: Plant[]
trayPlants: Plant[]
armedPlantId: number | null
onArm: (plant: Plant) => void
onRemove: (id: number) => void
@@ -23,7 +23,7 @@ export function SeedTray({
}) {
return (
<div className="flex flex-wrap items-center gap-1.5">
{plants.map((p) => {
{trayPlants.map((p) => {
const active = p.id === armedPlantId
return (
<span
@@ -59,7 +59,7 @@ export function SeedTray({
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"
>
Plant
+ Plant
</button>
</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[] {
try {
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 {
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. */
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
/** Remove a plant from the tray. */
remove: (id: number) => void
}
/** 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 [ids, setIds] = useState<number[]>(() => loadIds(gardenId))
+40 -28
View File
@@ -244,18 +244,26 @@ export function GardenEditorPage() {
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
// 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
// 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) {
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
ensurePlant(plant)
addToTray(plant)
if (picker === 'change' && selectedPlop) {
ensurePlant(plant)
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
} else {
armPlant(plant) // 'place' mode: arm for repeat placement
setArmedPlant(plant) // 'place' mode: arm for repeat placement
}
setPicker(null)
}
@@ -284,30 +292,34 @@ export function GardenEditorPage() {
{garden.name}
</button>
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
{canEdit && !focusedObject.plantable && <span className="text-xs text-muted">Not plantable</span>}
{canEdit && focusedObject.plantable && (
<SeedTray
plants={trayPlants}
armedPlantId={armedPlant?.id ?? null}
onArm={armPlant}
onRemove={removeFromTray}
onOpenPicker={() => setPicker('place')}
/>
)}
{canEdit && focusedObject.plantable && armedPlant && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Done
</Button>
)}
{canEdit && focusedObject.plantable && focusedPlops.length > 0 && (
<Button
variant="ghost"
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
onClick={() => setClearing(true)}
>
Clear ({focusedPlops.length})
</Button>
)}
{canEdit &&
(focusedObject.plantable ? (
<>
<SeedTray
trayPlants={trayPlants}
armedPlantId={armedPlant?.id ?? null}
onArm={armPlant}
onRemove={removeFromTrayAndDisarm}
onOpenPicker={() => setPicker('place')}
/>
{armedPlant && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Done
</Button>
)}
{focusedPlops.length > 0 && (
<Button
variant="ghost"
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
onClick={() => setClearing(true)}
>
Clear ({focusedPlops.length})
</Button>
)}
</>
) : (
<span className="text-xs text-muted">Not plantable</span>
))}
</div>
)}
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
@@ -355,7 +367,7 @@ export function GardenEditorPage() {
<PlantPicker
unit={garden.unitPref}
onClose={() => setPicker(null)}
onSelect={(p) => onPickPlant(p)}
onSelect={onPickPlant}
/>
)}