diff --git a/web/src/editor/SeedTray.tsx b/web/src/editor/SeedTray.tsx
new file mode 100644
index 0000000..cbfe6f1
--- /dev/null
+++ b/web/src/editor/SeedTray.tsx
@@ -0,0 +1,66 @@
+import { cn } from '@/lib/cn'
+import { PlantIcon } from '@/components/plants/PlantIcon'
+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 "+ Plant" chip
+ * opens the full catalog picker. See useSeedTray for persistence.
+ */
+export function SeedTray({
+ trayPlants,
+ armedPlantId,
+ onArm,
+ onRemove,
+ onOpenPicker,
+}: {
+ trayPlants: Plant[]
+ armedPlantId: number | null
+ onArm: (plant: Plant) => void
+ onRemove: (id: number) => void
+ onOpenPicker: () => void
+}) {
+ return (
+
+ )
+}
diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts
index e97b0be..a8da090 100644
--- a/web/src/lib/objects.ts
+++ b/web/src/lib/objects.ts
@@ -3,11 +3,12 @@
// PATCH with the row version; a 409 rolls back to the server's current row and
// toasts (see DESIGN § Sync).
+import { useCallback } from 'react'
import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
import { gardenSchema } from './gardens'
-import { plantSchema } from './plants'
+import { plantSchema, type Plant } from './plants'
import { serverPlantingSchema, type ServerPlanting } from './plantings'
import { toast } from '@/components/ui/toast'
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
@@ -77,6 +78,24 @@ export function useGardenFull(gardenId: number) {
return useQuery(gardenFullQueryOptions(gardenId))
}
+/**
+ * Ensure a plant is present in the /full cache's `plants` list. The full payload
+ * carries only the garden's *referenced* plants (ListReferencedPlants), so a
+ * plant chosen from the full catalog but not yet placed here is absent — its
+ * plops would render without an icon/color until the next refetch. Call this
+ * when arming/placing such a plant so it renders immediately. No-op if present.
+ */
+export function useEnsurePlantInFull(gardenId: number) {
+ const qc = useQueryClient()
+ return useCallback(
+ (plant: Plant) =>
+ patchFullCache(qc, gardenId, (full) =>
+ full.plants.some((p) => p.id === plant.id) ? full : { ...full, plants: [...full.plants, plant] },
+ ),
+ [qc, gardenId],
+ )
+}
+
// --- mutations -------------------------------------------------------------
export interface ObjectCreate {
diff --git a/web/src/lib/seedTray.ts b/web/src/lib/seedTray.ts
new file mode 100644
index 0000000..8db0347
--- /dev/null
+++ b/web/src/lib/seedTray.ts
@@ -0,0 +1,84 @@
+// The Seed Tray: a small, deliberately-curated set of plants a gardener keeps at
+// hand while planting a bed, so repeat placement is a single tap instead of a
+// trip through the full catalog. It's a per-garden convenience, persisted in
+// localStorage (same rationale as the PlantPicker's "recent plants"): a nicety,
+// not authoritative state, so a quota/availability failure is swallowed.
+//
+// Only plant ids are stored; they resolve against the live catalog on read, so a
+// renamed plant stays current and a deleted one silently drops out of the tray.
+
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { usePlants, type Plant } from './plants'
+
+const KEY = (gardenId: number) => `pansy:seed-tray:${gardenId}`
+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)) ?? '[]')
+ 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 []
+ }
+}
+
+function saveIds(gardenId: number, ids: number[]) {
+ try {
+ localStorage.setItem(KEY(gardenId), JSON.stringify(ids))
+ } catch {
+ // The tray is a convenience; ignore quota/availability failures.
+ }
+}
+
+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; 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): SeedTrayState {
+ const plants = usePlants()
+ const [ids, setIds] = useState(() => loadIds(gardenId))
+
+ // Re-read when switching gardens (the route param changes without remounting).
+ useEffect(() => {
+ setIds(loadIds(gardenId))
+ }, [gardenId])
+
+ const byId = useMemo(() => new Map((plants.data ?? []).map((p) => [p.id, p])), [plants.data])
+
+ // Resolve ids → plants, dropping any no longer in the catalog; preserve order.
+ const trayPlants = useMemo(
+ () => ids.map((id) => byId.get(id)).filter((p): p is Plant => !!p),
+ [ids, byId],
+ )
+
+ const add = useCallback(
+ (plant: Plant) =>
+ setIds((prev) => {
+ if (prev.includes(plant.id)) return prev
+ const next = [...prev, plant.id].slice(-TRAY_MAX)
+ saveIds(gardenId, next)
+ return next
+ }),
+ [gardenId],
+ )
+
+ const remove = useCallback(
+ (id: number) =>
+ setIds((prev) => {
+ const next = prev.filter((x) => x !== id)
+ saveIds(gardenId, next)
+ return next
+ }),
+ [gardenId],
+ )
+
+ return { trayPlants, add, remove }
+}
diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx
index e688beb..77ef159 100644
--- a/web/src/pages/GardenEditorPage.tsx
+++ b/web/src/pages/GardenEditorPage.tsx
@@ -7,6 +7,7 @@ import { Inspector } from '@/editor/Inspector'
import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker'
import { Palette } from '@/editor/Palette'
+import { SeedTray } from '@/editor/SeedTray'
import { ClearBedModal } from '@/editor/ClearBedModal'
import { EditorHint } from '@/editor/EditorHint'
import { objectDisplayName } from '@/editor/kinds'
@@ -14,8 +15,10 @@ import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { useMe } from '@/lib/auth'
-import { toEditorObject, useGardenFull, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
+import { toEditorObject, useEnsurePlantInFull, useGardenFull, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
+import type { Plant } from '@/lib/plants'
+import { useSeedTray } from '@/lib/seedTray'
import { usePageTitle } from '@/lib/usePageTitle'
const routeApi = getRouteApi('/gardens/$gardenId')
@@ -43,6 +46,8 @@ export function GardenEditorPage() {
const updatePlanting = useUpdatePlanting(gid)
const updateObject = useUpdateObject(gid)
+ const ensurePlant = useEnsurePlantInFull(gid)
+ const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
// 'change' swaps the selected plop's plant.
@@ -231,10 +236,30 @@ export function GardenEditorPage() {
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
const focusedPlops = focusedObjectId != null ? plantings.filter((p) => p.objectId === focusedObjectId) : []
- function onPickPlant(plantId: number) {
+ // Arm a plant for tap-to-place. The full catalog carries plants not yet in this
+ // garden's /full payload, so make sure the chosen one is in the cache first —
+ // otherwise its placed plops render without an icon/color until a refetch.
+ function armPlant(plant: Plant) {
+ ensurePlant(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
+ // 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). 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
- const plant = plantsById.get(plantId)
- if (!plant) return
+ ensurePlant(plant)
+ addToTray(plant)
if (picker === 'change' && selectedPlop) {
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
} else {
@@ -268,26 +293,33 @@ export function GardenEditorPage() {
{objectDisplayName(focusedObject)}
{canEdit &&
- (!focusedObject.plantable ? (
- Not plantable
- ) : armedPlant ? (
-
+ (focusedObject.plantable ? (
+ <>
+ setPicker('place')}
+ />
+ {armedPlant && (
+
+ )}
+ {focusedPlops.length > 0 && (
+
+ )}
+ >
) : (
-
+ Not plantable
))}
- {canEdit && focusedObject.plantable && focusedPlops.length > 0 && (
-
- )}
)}
@@ -297,7 +329,7 @@ export function GardenEditorPage() {
Pick a shape from the palette, then tap the field to place your first bed.
)}
{canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && (
- Tap “+ Add plant”, then tap inside the bed to place plops.
+ Pick a plant from the tray, then tap inside the bed to place it.
)}
@@ -335,7 +367,7 @@ export function GardenEditorPage() {
setPicker(null)}
- onSelect={(p) => onPickPlant(p.id)}
+ onSelect={onPickPlant}
/>
)}