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' import { GardenCanvas } from '@/editor/GardenCanvas' import { EditorRail, type RailTab } from '@/editor/EditorRail' import { HistoryPanel } from '@/editor/HistoryPanel' import { ChatPanel } from '@/editor/ChatPanel' import { JournalPanel } from '@/editor/JournalPanel' 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 { RecentPlants } from '@/editor/RecentPlants' import { ScanPacketModal } from '@/components/plants/ScanPacketModal' import { ClearBedModal } from '@/editor/ClearBedModal' 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' import { useMe } from '@/lib/auth' import { toEditorObject, useEnsurePlantInFull, useFillObject, useGardenFull, useGardenSeason, useGardenYears, useUpdateObject, useUpdatePlanting, type FillLayout, } from '@/lib/objects' import { recentlyPlantedIds, toEditorPlanting } from '@/lib/plantings' import type { Plant } from '@/lib/plants' import { useCapabilities } from '@/lib/agent' import { useJournalCounts } from '@/lib/journal' import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots' import { useSeedTray } from '@/lib/seedTray' import { usePageTitle } from '@/lib/usePageTitle' import { rememberLastGarden, forgetLastGarden } from '@/lib/lastGarden' import { ApiError } from '@/lib/api' const routeApi = getRouteApi('/gardens/$gardenId') // How many recently-planted chips the Plants-mode quick strip shows — a working // set, not the whole history; the picker covers the long tail. const RECENT_PLANTS_MAX = 8 export function GardenEditorPage() { const { gardenId } = routeApi.useParams() const gid = Number(gardenId) const { focus } = routeApi.useSearch() const navigate = routeApi.useNavigate() const seasonYear = useEditorStore((s) => s.seasonYear) const setSeasonYear = useEditorStore((s) => s.setSeasonYear) // Two queries, one shown. The live one stays mounted so returning to "now" is // instant and so the optimistic mutation cache it owns is never displaced. const live = useGardenFull(gid) const season = useGardenSeason(gid, seasonYear) const full = seasonYear === null ? live : season const years = useGardenYears(gid) const me = useMe() usePageTitle(full.data?.garden.name ?? 'Garden') // The garden itself is gone (deleted, or access revoked) — keyed on the LIVE // query, since whether the garden EXISTS doesn't depend on the season being // viewed. Both the bounce effect and the "gone" render message read this one // value, so the promise ("taking you to your gardens…") and the redirect can't // disagree — e.g. a season-view error while the live garden is fine must not // claim a redirect that never fires. const gardenGone = live.error instanceof ApiError && live.error.isNotFound const selectedId = useEditorStore((s) => s.selectedId) const select = useEditorStore((s) => s.select) const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId) const selectPlanting = useEditorStore((s) => s.selectPlanting) const focusedObjectId = useEditorStore((s) => s.focusedObjectId) const setFocusedObject = useEditorStore((s) => s.setFocusedObject) const railTab = useEditorStore((s) => s.railTab) const setRailTab = useEditorStore((s) => s.setRailTab) const journalObjectId = useEditorStore((s) => s.journalObjectId) const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId) const journalCounts = useJournalCounts(gid) const capabilities = useCapabilities() const journalTotal = useMemo( () => [...(journalCounts.data?.values() ?? [])].reduce((a, b) => a + b, 0), [journalCounts.data], ) const armedPlant = useEditorStore((s) => s.armedPlant) const setArmedPlant = useEditorStore((s) => s.setArmedPlant) const mode = useEditorStore((s) => s.mode) const setMode = useEditorStore((s) => s.setMode) const updatePlanting = useUpdatePlanting(gid) const fillObject = useFillObject(gid) const updateObject = useUpdateObject(gid) const ensurePlant = useEnsurePlantInFull(gid) const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid) const seedLots = useSeedLots() const lotsByPlantId = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data]) // Which plant-picker flow is open: 'place' arms a plant for repeat placement; // 'change' swaps the selected plop's plant. const [picker, setPicker] = useState<'place' | 'change' | null>(null) const [sharing, setSharing] = useState(false) const [clearing, setClearing] = useState(false) const [scanning, setScanning] = useState(false) // Whether to offer packet scanning — a vision model is configured. Read once // here so all three Plants-mode entry points gate identically. const canScan = !!capabilities.data?.vision const nudgeTimer = useRef(null) const nudgeFire = useRef<(() => void) | null>(null) const serverObjects = full.data?.objects const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects]) const serverPlantings = full.data?.plantings const plantings = useMemo(() => serverPlantings?.map(toEditorPlanting) ?? [], [serverPlantings]) const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants]) const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants]) // Plants recently placed in THIS garden, newest first (#100) — the quick strip // in Plants mode, so re-planting "more of the same" doesn't need the picker. const recentPlants = useMemo( () => recentlyPlantedIds(plantings) .slice(0, RECENT_PLANTS_MAX) .map((id) => plantsById.get(id)) .filter((p): p is Plant => !!p), [plantings, plantsById], ) // Role gating, computed before the effects/returns so the nudge handler can use // it. Ownership is the authoritative ownerId==me check. const gd = full.data?.garden // A past season is read-only: it is a record of what happened, and editing it // would be editing the past. This is the single gate — the palette, inspector, // nudging, placement and drag handles all key off canEdit. const canEdit = seasonYear === null && gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor') const isOwner = gd != null && me.data != null && gd.ownerId === me.data.id // Latest values for the mount-once nudge keydown handler to read, so its effect // never re-subscribes (which would cancel a pending debounced commit). const nudgeCtx = useRef({ canEdit, objects, plantings, updateObject, updatePlanting }) nudgeCtx.current = { canEdit, objects, plantings, updateObject, updatePlanting } // Adopt the URL's focus and clear transient editor state on entering/switching // gardens (and on leaving). `focus` is intentionally read once here, not a dep, // so URL syncs below don't retrigger a full reset. useEffect(() => { // resetTransient is the single list of ephemeral editor state, so new state // (the rail tab did exactly this) can't be forgotten here. const clear = () => useEditorStore.getState().resetTransient() clear() setFocusedObject(focus ?? null) return () => { clear() setFocusedObject(null) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [gid]) // Mirror focusedObjectId → ?focus so focus mode is deep-linkable and survives // reload. Declared after the reset effect so the initial adopt wins. useEffect(() => { navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true }) }, [focusedObjectId, navigate]) // Remember this garden as the device's last-opened, so `/` resumes here next // visit. Keyed on the live query: which garden EXISTS doesn't depend on the // season being viewed. useEffect(() => { if (live.isSuccess) rememberLastGarden(gid) }, [live.isSuccess, gid]) // A last garden that 404s (deleted, or access revoked) must not strand the user // on an error screen the `/` resume keeps returning to: forget it (only if it's // this one, so a bad direct link can't wipe a good resume target) and bounce to // the list. Transient errors (500/network) fall through to the retryable screen. useEffect(() => { if (gardenGone) { forgetLastGarden(gid) navigate({ to: '/gardens' }) } }, [gardenGone, gid, navigate]) // If the focused object vanished — deleted, or a stale ?focus id on load — leave // focus mode so the canvas isn't stuck dimmed with no way out. useEffect(() => { if (focusedObjectId != null && full.data && !objects.some((o) => o.id === focusedObjectId)) { setFocusedObject(null) } }, [focusedObjectId, objects, full.data, setFocusedObject]) // The canvas mode follows focus: inside a bed you're placing Plants, out of it // you're arranging Fixtures — so the bottom strip can't show the object palette // while you're in a bed, or the seed tray while you're not. A panel mode // (journal/assistant) is set explicitly and left alone here. (Inert on desktop, // where the mode bar isn't shown.) useEffect(() => { const cur = useEditorStore.getState().mode if (focusedObjectId != null) { if (cur !== 'journal' && cur !== 'assistant') setMode('plants') } else if (cur === 'plants') { setMode('fixtures') } }, [focusedObjectId, setMode]) // Selecting anything lands you in the inspector without a click — the rail // must never be something you operate before you can edit. Keyed off the // selected ids rather than a "something is selected" boolean, so selecting a // DIFFERENT object while History is open still brings the inspector forward. // Deselecting drops back out of the inspector but leaves History/Chat open if // that's where you were, since those aren't about the selection. Selecting is a // canvas interaction, so it also leaves a panel mode — otherwise closing the // inspector would strand the mode bar on Journal/Assistant with nothing open. useEffect(() => { if (selectedId != null || selectedPlantingId != null) { setRailTab('inspector') const s = useEditorStore.getState() if (s.mode === 'journal' || s.mode === 'assistant') { setMode(s.focusedObjectId != null ? 'plants' : 'fixtures') } } else if (useEditorStore.getState().railTab === 'inspector') { setRailTab(null) } }, [selectedId, selectedPlantingId, setRailTab, setMode]) // Clearing the selection also closes the inspector via the selection effect // above; shared by exitFocus, the mode bar, and the rail's close so the // deselect logic lives in one place. const clearSelection = () => { select(null) selectPlanting(null) } const exitFocus = () => { setFocusedObject(null) setArmedPlant(null) clearSelection() } // The mobile mode bar. Journal/Assistant are panel modes, so they open the rail // as a peek; Fixtures/Plants are canvas modes — a fresh intent — so they close // whatever's in the rail AND drop any lingering selection (which the canvas // would otherwise keep highlighted, e.g. after routing a selection through // Journal and back). Fixtures also steps out of a focused bed. const selectMode = (m: EditorMode) => { setMode(m) if (m === 'journal') { setJournalObjectId(null) setRailTab('journal') } else if (m === 'assistant') { setRailTab('chat') } else { clearSelection() setRailTab(null) if (m === 'fixtures' && focusedObjectId != null) exitFocus() } } // Safety for a live capability flip: if the admin turns the assistant off while // it's the active mode, drop back to a canvas mode so the bar isn't stuck on a // tab that no longer exists (and close the now-orphaned chat rail). useEffect(() => { if (!capabilities.data?.agent && useEditorStore.getState().mode === 'assistant') { setMode('fixtures') if (useEditorStore.getState().railTab === 'chat') setRailTab(null) } }, [capabilities.data?.agent, setMode, setRailTab]) // Escape peels back one layer: stop placing → deselect plop → exit focus → deselect. useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key !== 'Escape') return const s = useEditorStore.getState() if (s.armedPlant) s.setArmedPlant(null) else if (s.selectedPlantingId != null) s.selectPlanting(null) else if (s.focusedObjectId != null) exitFocus() else if (s.selectedId != null) s.select(null) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 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). // 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(() => { nudgeTimer.current = null const fn = nudgeFire.current nudgeFire.current = null fn?.() }, 400) }, []) const nudgeSelected = useCallback((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) }) } }, [commitLater]) // 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], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], } function onKey(e: KeyboardEvent) { 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.selectedId == null && s.selectedPlantingId == null) return e.preventDefault() const step = e.shiftKey ? 10 : 1 nudgeSelected(dir[0] * step, dir[1] * step) } window.addEventListener('keydown', onKey) return () => { window.removeEventListener('keydown', onKey) if (nudgeTimer.current != null) { window.clearTimeout(nudgeTimer.current) nudgeTimer.current = null } const fn = nudgeFire.current // flush a pending move so it isn't lost nudgeFire.current = null fn?.() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) if (full.isPending) return

Loading garden…

if (full.isError) { // Only promise the bounce when the GARDEN is gone (the effect above keys on // the same gardenGone). A season-view error while the live garden is fine is a // different, non-redirecting failure and gets the generic message. return (
{gardenGone ? 'That garden is no longer available — taking you to your gardens…' : 'Could not load this garden.'}
) } const g = full.data.garden const garden: EditorGarden = { id: g.id, name: g.name, widthCm: g.widthCm, heightCm: g.heightCm, unitPref: g.unitPref, gridSizeCm: g.gridSizeCm, snapToGrid: g.snapToGrid, } const selectedObject = objects.find((o) => o.id === selectedId) ?? null const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null // Committed selected plop; PlopInspector applies live drag geometry itself. const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null const focusedPlops = focusedObjectId != null ? plantings.filter((p) => p.objectId === focusedObjectId) : [] // 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. // The Seed Tray arms a plant directly, without going through the picker, so it // has to do the picker's single-lot auto-attribution itself — otherwise the // quickest way to place a plant is the one path that silently loses which // packet it came from. Several lots is genuinely ambiguous, so that case stays // unattributed rather than guessing; the picker is where you choose. function armPlant(plant: Plant, lot?: SeedLot) { ensurePlant(plant) if (lot === undefined) { const lots = attributableLots(lotsByPlantId.get(plant.id) ?? []) lot = lots.length === 1 ? lots[0] : undefined } setArmedPlant(plant, lot?.id ?? null) } // 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) } // Fill the whole focused bed with the armed plant at the chosen layout (#100 / // #77) — the UI's way to run the region fill that was agent-only before. Guards // are belt-and-braces: the control only shows with a plant armed in a bed. function fillBed(layout: FillLayout) { if (!canEdit || focusedObject == null || armedPlant == null) return fillObject.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, layout }) } // 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, lot?: SeedLot) { if (!canEdit) return // defense in depth: viewers can't reach the picker anyway ensurePlant(plant) addToTray(plant) if (picker === 'change' && selectedPlop) { updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id }) } else { setArmedPlant(plant, lot?.id ?? null) // 'place' mode: arm for repeat placement } setPicker(null) } // One rail, tabs inside it — the layout decision #49 settles for the journal // (#53) and chat (#57) panels too: they add a tab here rather than each // claiming their own strip of screen. const railTabs: RailTab[] = [ { id: 'inspector', label: 'Inspector', render: () => selectedObject ? ( { setFocusedObject(selectedObject.id) select(null) }} noteCount={journalCounts.data?.get(selectedObject.id) ?? 0} onAddNote={() => { // Two taps from a selected bed to typing: scope the journal to it // and switch tabs. The composer is already open in there. setJournalObjectId(selectedObject.id) setRailTab('journal') }} /> ) : selectedPlop ? ( setPicker('change')} onClose={() => selectPlanting(null)} /> ) : (

Select a bed or a planting to edit it.

), }, { id: 'journal', label: 'Journal', // The total on the tab is the discoverability half: a garden with a // season's notes in it shouldn't look identical to an empty one. badge: journalTotal, render: () => ( ), }, { id: 'history', label: 'History', render: () => , }, ] // Only when the instance actually has the assistant configured. A tab that // opens onto an apology is worse than no tab. if (capabilities.data?.agent) { railTabs.push({ id: 'chat', label: 'Assistant', render: () => , }) } // 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport // (URL bar hidden), so with the bar showing the editor overflowed and pushed // the canvas bottom + Fit button under the browser chrome (#85). return (
{/* Desktop-only control column. On mobile these move to the bottom mode bar + a slim top strip so the canvas — the point of the screen — isn't shoved into a corner by a stack of controls (#99). */}

{garden.name}

{isOwner && ( )} {!canEdit && seasonYear === null && (

👁 View only

)} {years.data && years.data.length > 0 && (
)} {focusedObjectId == null && canEdit && } {capabilities.data?.agent && ( )}
{/* Mobile top strip: the garden identity / season / share that live in the desktop left column. md:hidden. */}

{garden.name}

{!canEdit && seasonYear === null && ( 👁 View only )} {years.data && years.data.length > 0 && ( )} {isOwner && ( )}
{seasonYear !== null && setSeasonYear(null)} />} {/* Focus toolbar is desktop-only; on mobile its plant tools move to the bottom Plants-mode strip so they don't wrap over the canvas. */} {focusedObject && (
{objectDisplayName(focusedObject)} {canEdit && (focusedObject.plantable ? ( setPicker('place')} onDisarm={() => setArmedPlant(null)} focusedPlopCount={focusedPlops.length} onClear={() => setClearing(true)} onFill={fillBed} filling={fillObject.isPending} canScan={canScan} onScan={() => setScanning(true)} /> ) : ( Not plantable ))}
)}
{/* Touch fine-positioning: the keyboard's arrow-nudge has no equivalent 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) && ( )}
{/* Empty-state hints (non-interactive overlays). */} {canEdit && focusedObjectId == null && objects.length === 0 && ( Pick a shape from the palette, then tap the field to place your first bed. )} {canEdit && focusedObject?.plantable && focusedPlops.length === 0 && !armedPlant && ( Pick a plant from the tray, then tap inside the bed to place it. )}
{/* The rail sits between the canvas and the mode bar. On mobile it's an in-flow PEEK (≤50vh), so the garden stays visible above it and the mode bar below (see EditorRail); on desktop it's the right-hand column. */} {railTab && ( { // Only the inspector is *about* the selection, so only closing it // deselects; dismissing a panel leaves the canvas as you had it. Any // panel rail (journal/history/chat) drops back to a canvas mode. if (railTab === 'inspector') { clearSelection() } else { // Back to a canvas mode — Plants if you're still inside a bed, else // Fixtures. (Hardcoding Fixtures here docked the object palette inside // a focused bed.) setMode(focusedObjectId != null ? 'plants' : 'fixtures') } setRailTab(null) }} /> )} {/* Mobile bottom: contextual tools for the current canvas mode + the always-visible mode switch bar (#99/#101). md:hidden — desktop uses the left column. The tool strip yields to a rail peek when one is open. */}
{canEdit && !railTab && (mode === 'fixtures' || mode === 'plants') && (
{mode === 'fixtures' && } {mode === 'plants' && (focusedObject ? ( focusedObject.plantable ? (
setPicker('place')} onDisarm={() => setArmedPlant(null)} focusedPlopCount={focusedPlops.length} onClear={() => setClearing(true)} onFill={fillBed} filling={fillObject.isPending} canScan={!!capabilities.data?.vision} onScan={() => setScanning(true)} />
) : ( // Reachable via a ?focus= deep link onto a non-plantable object: // say so, and give a way back out (there's no focus toolbar here).
{objectDisplayName(focusedObject)} isn’t plantable.
) ) : (

Tap a bed, then “🌱 Plant here” to start planting.

{canScan && ( )}
))}
)}
{picker && ( setPicker(null)} onSelect={onPickPlant} /> )} {sharing && setSharing(false)} />} {scanning && setScanning(false)} />} {clearing && focusedObject && ( setClearing(false)} /> )}
) } // The plant-placement cluster (seed tray + Done + Clear), shared by the desktop // focus toolbar and the mobile Plants strip so the two can't drift apart. function PlantPlacementTools({ recentPlants, trayPlants, armedPlant, onArm, onRemove, onOpenPicker, onDisarm, focusedPlopCount, onClear, onFill, filling, canScan, onScan, }: { recentPlants: Plant[] trayPlants: Plant[] armedPlant: Plant | null onArm: (p: Plant, lot?: SeedLot) => void onRemove: (id: number) => void onOpenPicker: () => void onDisarm: () => void focusedPlopCount: number onClear: () => void onFill: (layout: FillLayout) => void filling: boolean // Scanning a packet adds a variety to the catalog mid-planting; only offered // where a vision model is configured. canScan: boolean onScan: () => void }) { return ( <> {canScan && ( )} {armedPlant && } {armedPlant && ( )} {focusedPlopCount > 0 && ( )} ) } // The fill control (#100 / #77): choose a layout, then fill the whole bed with // the armed plant. Clump packs fat blobs for a quick sketch; grid lays out // individual plants in rows you could actually plant from. Defaults to grid, // since "fill this bed" usually means a real planting. function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; busy: boolean }) { const [layout, setLayout] = useState('grid') return ( {(['clump', 'grid'] as const).map((l) => ( ))} ) } // 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 ' + '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. const MODES: { id: EditorMode; label: string; icon: string }[] = [ { id: 'fixtures', label: 'Fixtures', icon: '🛠️' }, { id: 'plants', label: 'Plants', icon: '🌱' }, { id: 'journal', label: 'Journal', icon: '📓' }, { id: 'assistant', label: 'Assistant', icon: '💬' }, ] function ModeBar({ mode, onSelect, hasAssistant, canEdit, }: { mode: EditorMode onSelect: (m: EditorMode) => void hasAssistant: boolean canEdit: boolean }) { const modes = MODES.filter((m) => { if (m.id === 'assistant') return hasAssistant // Fixtures/Plants are edit actions — a viewer can't place anything, so the // bar offers only what they can do (read the journal, ask the assistant). if (m.id === 'fixtures' || m.id === 'plants') return canEdit return true }) return (
{modes.map((m) => { const active = mode === m.id return ( ) })}
) }