Files
pansy/web/src/pages/GardenEditorPage.tsx
T
steveandClaude Fable 5 7a5b9d2ea1
Build image / build-and-push (push) Successful in 12s
Address #133 review: the toolkit has one home now
The `embedded` prop was always true, which left the card branch dead;
the component is simply the rail's tab now. The focus comment is one
line, and the default tab says why it is Plot and not the first tab.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 03:04:22 -04:00

671 lines
29 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, getRouteApi } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { ScanPacketDialog } from '@/components/plants/ScanPacketDialog'
import { Nav } from '@/components/layout/Nav'
import { ThemeButton } from '@/components/layout/ThemeButton'
import { Alert } from '@/components/ui/Alert'
import { Button, IconButton } from '@/components/ui/Button'
import { Icon, type IconName } from '@/components/ui/Icon'
import { Seg } from '@/components/ui/Seg'
import { ColorDot } from '@/components/plants/Monogram'
import { AssistantTab } from '@/editor/AssistantTab'
import { Canvas, type CanvasHandle } from '@/editor/Canvas'
import { ClearBedDialog } from '@/editor/ClearBedDialog'
import { HistoryTab } from '@/editor/HistoryTab'
import { GardenSummary, ObjectInspector, PlopInspector, plantCountIn, rosterText } from '@/editor/Inspector'
import { JournalTab, type JournalAttach } from '@/editor/JournalTab'
import { KindSwatch } from '@/editor/KindSwatch'
import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds'
import { useSeasons } from '@/editor/seasons'
import { PHONE_BREAKPOINT, isTyping } from '@/editor/shared'
import { useEditorStore, type PhoneMode, type RailTab } from '@/editor/store'
import { FillRow, LotChooser, Toolkit } from '@/editor/Toolkit'
import { lotChoices, orderedPlants, toggleArmedKind, toggleArmedPlant } from '@/editor/arming'
import type { EditorGarden, EditorObject } from '@/editor/types'
import { useUndoLast } from '@/editor/useUndoLast'
import { useCapabilities } from '@/lib/agent'
import { ApiError } from '@/lib/api'
import { useMe } from '@/lib/auth'
import { cn } from '@/lib/cn'
import { useGardens, type Garden } from '@/lib/gardens'
import { historyKey } from '@/lib/history'
import { useJournalCounts } from '@/lib/journal'
import { forgetLastGarden, rememberLastGarden } from '@/lib/lastGarden'
import { monogramMap } from '@/lib/monogram'
import {
toEditorObject,
useDeleteObject,
useEnsurePlantInFull,
useFillObject,
useGardenFull,
useGardenSeason,
useRemovePlanting,
useUpdateObject,
useUpdatePlanting,
type FullGarden,
} from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
import { usePlants, type Plant } from '@/lib/plants'
import { lotsByPlant, useSeedLots } from '@/lib/seedLots'
import { formatSize } from '@/lib/units'
import { usePageTitle } from '@/lib/usePageTitle'
const routeApi = getRouteApi('/gardens/$gardenId')
/** Loads the garden (live, or a past season) and guards the edge cases; the
* Editor below is everything else. */
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)
// Two queries, one shown: the live one stays mounted so returning to "now" is
// instant and 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 me = useMe()
usePageTitle(full.data?.garden.name ?? 'Garden')
// Keyed on the LIVE query: whether the garden EXISTS doesn't depend on the
// season being viewed.
const gardenGone = live.error instanceof ApiError && live.error.isNotFound
// Fresh transient state on entering/switching gardens, adopting the URL's
// focus. `focus` is read once here on purpose.
useEffect(() => {
const st = useEditorStore.getState()
st.reset()
st.setFocus(focus ?? null)
return () => useEditorStore.getState().reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gid])
useEffect(() => {
if (live.isSuccess) rememberLastGarden(gid)
}, [live.isSuccess, gid])
useEffect(() => {
if (gardenGone) {
forgetLastGarden(gid)
navigate({ to: '/gardens' })
}
}, [gardenGone, gid, navigate])
if (full.isPending) return <p className="p-7 text-[13px] font-semibold text-ink-mute">Loading the garden</p>
if (full.isError) {
return (
<div className="p-7">
<Alert>{gardenGone ? 'That garden is no longer available — taking you to your gardens…' : 'Could not load this garden.'}</Alert>
</div>
)
}
const g = full.data.garden
const canEdit = seasonYear === null && (g.ownerId === me.data?.id || g.myRole === 'editor')
const isOwner = me.data != null && g.ownerId === me.data.id
return <Editor gid={gid} data={full.data} canEdit={canEdit} isOwner={isOwner} currentUserId={me.data?.id} />
}
const MODES: { id: PhoneMode; label: string; icon: IconName }[] = [
{ id: 'build', label: 'Build', icon: 'shovel' },
{ id: 'plants', label: 'Plants', icon: 'sprout' },
{ id: 'journal', label: 'Journal', icon: 'notebook' },
{ id: 'chat', label: 'Assistant', icon: 'message-circle' },
]
const TABS: { id: RailTab; label: string }[] = [
{ id: 'toolkit', label: 'Toolkit' },
{ id: 'plot', label: 'Plot' },
{ id: 'journal', label: 'Journal' },
{ id: 'history', label: 'History' },
{ id: 'chat', label: 'Assistant' },
]
/**
* The editor: one canvas, two chromes. Above 760px of container width it is the
* two-card workspace — the plan, and a rail whose first tab is the toolkit
* (the handoff drew it as a third card on the left; folding it into the rail
* gave the plan and the rail its width); below, a phone layout with the canvas
* as the whole screen, a peek panel that docks between canvas and mode bar,
* and a tool strip for the current mode. Same state, same components.
*/
function Editor({
gid,
data,
canEdit,
isOwner,
currentUserId,
}: {
gid: number
data: FullGarden
canEdit: boolean
isOwner: boolean
currentUserId?: number
}) {
const navigate = routeApi.useNavigate()
const g: Garden = data.garden
const garden: EditorGarden = useMemo(
() => ({ id: g.id, name: g.name, widthCm: g.widthCm, heightCm: g.heightCm, unitPref: g.unitPref, gridSizeCm: g.gridSizeCm, snapToGrid: g.snapToGrid }),
[g.id, g.name, g.widthCm, g.heightCm, g.unitPref, g.gridSizeCm, g.snapToGrid],
)
const unit = g.unitPref
const gardens = useGardens()
const capabilities = useCapabilities()
const catalog = usePlants()
const seedLots = useSeedLots()
const journalCounts = useJournalCounts(gid)
const lotsByPlantId = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
const sel = useEditorStore((s) => s.sel)
const setSel = useEditorStore((s) => s.setSel)
const focusId = useEditorStore((s) => s.focusId)
const setFocus = useEditorStore((s) => s.setFocus)
const armedPlant = useEditorStore((s) => s.armedPlant)
const armedLotId = useEditorStore((s) => s.armedLotId)
const setArmedLotId = useEditorStore((s) => s.setArmedLotId)
const armedKind = useEditorStore((s) => s.armedKind)
const tab = useEditorStore((s) => s.tab)
const setTab = useEditorStore((s) => s.setTab)
const mode = useEditorStore((s) => s.mode)
const setMode = useEditorStore((s) => s.setMode)
const setJournalScope = useEditorStore((s) => s.setJournalScope)
const updateObject = useUpdateObject(gid)
const updatePlanting = useUpdatePlanting(gid)
const deleteObject = useDeleteObject(gid)
const removePlanting = useRemovePlanting(gid)
const fillObject = useFillObject(gid)
const ensurePlant = useEnsurePlantInFull(gid)
const undoLast = useUndoLast(gid, true)
const seasons = useSeasons(g, gardens.data)
// Every successful mutation is a history entry, and the history list is
// always mounted here (the Undo button reads it), so it never gets the
// on-open refetch it used to rely on. Refresh it whenever anything lands.
const qc = useQueryClient()
useEffect(
() =>
qc.getMutationCache().subscribe((event) => {
if (event.type === 'updated' && event.action.type === 'success') void qc.invalidateQueries({ queryKey: historyKey(gid) })
}),
[qc, gid],
)
const objects = useMemo(() => data.objects.map(toEditorObject), [data.objects])
const plantings = useMemo(() => data.plantings.map(toEditorPlanting), [data.plantings])
// The full payload carries the garden's referenced plants; the catalog, the
// rest. Markers need both (a just-armed plant isn't referenced yet), and the
// monogram collision set is the whole catalog so a letter means the same
// thing on every screen.
const plantsById = useMemo(() => {
const m = new Map<number, Plant>()
for (const p of data.plants) m.set(p.id, p)
for (const p of catalog.data ?? []) m.set(p.id, p)
return m
}, [data.plants, catalog.data])
const plants = useMemo(() => [...plantsById.values()], [plantsById])
const letters = useMemo(() => monogramMap(plants), [plants])
// The container's width, not the viewport's, decides the chrome.
const rootRef = useRef<HTMLDivElement>(null)
const [vw, setVw] = useState(() => (typeof window !== 'undefined' ? window.innerWidth : 1024))
useEffect(() => {
const el = rootRef.current
if (!el) return
const ro = new ResizeObserver(([entry]) => setVw(entry.contentRect.width))
ro.observe(el)
return () => ro.disconnect()
}, [])
const isMobile = vw < PHONE_BREAKPOINT
const canvasRef = useRef<CanvasHandle>(null)
const [clearing, setClearing] = useState(false)
const [scanning, setScanning] = useState(false)
const focused = focusId != null ? (objects.find((o) => o.id === focusId) ?? null) : null
const focusedPlops = useMemo(() => (focusId != null ? plantings.filter((p) => p.objectId === focusId) : []), [plantings, focusId])
const selectedObject = sel?.type === 'object' ? (objects.find((o) => o.id === sel.id) ?? null) : null
const selectedPlop = sel?.type === 'plop' ? (plantings.find((p) => p.id === sel.id) ?? null) : null
const hasAssistant = !!capabilities.data?.agent
const canScan = !!capabilities.data?.vision
// Mirror focus → ?focus so a bed is deep-linkable and survives reload.
useEffect(() => {
navigate({ search: (prev) => ({ ...prev, focus: focusId ?? undefined }), replace: true })
}, [focusId, navigate])
// A focused bed that vanished (deleted, or a stale ?focus) must not leave the
// canvas dimmed with no way out.
useEffect(() => {
if (focusId != null && !objects.some((o) => o.id === focusId)) {
setFocus(null)
canvasRef.current?.zoomFit()
}
}, [focusId, objects, setFocus])
// The phone's mode follows focus: inside a bed you're planting.
useEffect(() => {
const cur = useEditorStore.getState().mode
if (focusId != null) {
if (cur === 'build') setMode('plants')
} else if (cur === 'plants') setMode('build')
}, [focusId, setMode])
// A plant chosen from the catalog may not be in this garden's payload yet;
// make sure its plops render with a color the moment they're placed.
useEffect(() => {
if (armedPlant) ensurePlant(armedPlant)
}, [armedPlant, ensurePlant])
// If the assistant is turned off under us, don't strand the UI on its tab.
useEffect(() => {
if (capabilities.data && !capabilities.data.agent) {
if (useEditorStore.getState().tab === 'chat') setTab('plot')
if (useEditorStore.getState().mode === 'chat') setMode('build')
}
}, [capabilities.data, setTab, setMode])
const exitFocus = useCallback(() => {
const st = useEditorStore.getState()
st.setFocus(null)
st.setArmedPlant(null)
st.setSel(null)
if (isMobile) st.setMode('build')
canvasRef.current?.zoomFit()
}, [isMobile])
const plantThis = (o: EditorObject) => canvasRef.current?.focusObject(o)
const openNotes = (scope: { type: 'object' | 'plop'; id: number }) => {
setJournalScope(scope)
setTab('journal')
if (isMobile) {
setSel(null)
setMode('journal')
}
}
// Latest values for the window keydown handler, which mounts once.
const latest = useRef({ canEdit, objects, plantings, selectedObject, selectedPlop, focused, exitFocus, undoLast, updateObject, updatePlanting, deleteObject, removePlanting })
latest.current = { canEdit, objects, plantings, selectedObject, selectedPlop, focused, exitFocus, undoLast, updateObject, updatePlanting, deleteObject, removePlanting }
const nudgeTimer = useRef<number | null>(null)
const nudgeCommit = useRef<(() => void) | null>(null)
useEffect(() => {
const DIRS: Record<string, [number, number]> = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
function onKey(e: KeyboardEvent) {
if (isTyping()) return
const st = useEditorStore.getState()
const L = latest.current
if (e.key === 'Escape') {
if (st.armedKind || st.armedPlant) {
st.setArmedKind(null)
st.setArmedPlant(null)
st.setGhost(null)
} else if (st.sel) st.setSel(null)
else if (st.focusId != null) L.exitFocus()
return
}
if ((e.key === 'Delete' || e.key === 'Backspace') && st.sel && L.canEdit) {
e.preventDefault()
if (st.sel.type === 'object') {
if (st.focusId === st.sel.id) L.exitFocus()
st.setSel(null)
L.deleteObject.mutate(st.sel.id)
} else if (L.selectedPlop) {
st.setSel(null)
L.removePlanting.mutate({ id: L.selectedPlop.id, version: L.selectedPlop.version })
}
return
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !e.shiftKey) {
if (L.canEdit && L.undoLast.canUndo) {
e.preventDefault()
L.undoLast.undoLast()
}
return
}
const dir = DIRS[e.key]
if (!dir || !st.sel || !L.canEdit) return
e.preventDefault()
const step = e.shiftKey ? 10 : 1
const dx = dir[0] * step
const dy = dir[1] * step
// Live geometry now, one PATCH after the burst settles.
if (st.sel.type === 'object') {
const base = st.liveObject?.id === st.sel.id ? st.liveObject : L.objects.find((o) => o.id === st.sel!.id)
if (!base) return
st.setLiveObject({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy })
nudgeCommit.current = () => {
const live = useEditorStore.getState().liveObject
if (!live) return
L.updateObject.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
useEditorStore.getState().setLiveObject(null)
}
} else {
const base = st.livePlanting?.id === st.sel.id ? st.livePlanting : L.plantings.find((p) => p.id === st.sel!.id)
if (!base) return
st.setLivePlanting({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy })
nudgeCommit.current = () => {
const live = useEditorStore.getState().livePlanting
if (!live) return
L.updatePlanting.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
useEditorStore.getState().setLivePlanting(null)
}
}
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
nudgeTimer.current = window.setTimeout(() => {
nudgeTimer.current = null
const fn = nudgeCommit.current
nudgeCommit.current = null
fn?.()
}, 400)
}
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
nudgeCommit.current?.() // flush a pending nudge so it isn't lost
nudgeCommit.current = null
}
}, [])
const banner = seasons.banner ?? (!canEdit ? 'You can look but not change this garden — ask the owner for editor access.' : null)
// What a new journal note attaches to: selection, else the focused bed, else the garden.
const attach: JournalAttach = selectedPlop
? { plantingId: selectedPlop.id, label: plantsById.get(selectedPlop.plantId)?.name ?? 'this planting' }
: selectedObject
? { objectId: selectedObject.id, label: objectDisplayName(selectedObject) }
: focused
? { objectId: focused.id, label: objectDisplayName(focused) }
: { label: null }
const inspector = selectedObject ? (
<ObjectInspector
key={`o${selectedObject.id}`}
object={selectedObject}
gardenId={gid}
unit={unit}
canEdit={canEdit}
focused={focusId === selectedObject.id}
roster={rosterText(selectedObject, plantings, plantsById)}
plantCount={plantCountIn(selectedObject, plantings, plantsById)}
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
large={isMobile}
onPlantThis={() => plantThis(selectedObject)}
onNotes={() => openNotes({ type: 'object', id: selectedObject.id })}
onDeleted={() => {
if (focusId === selectedObject.id) exitFocus()
setSel(null)
}}
/>
) : selectedPlop ? (
<PlopInspector
key={`p${selectedPlop.id}`}
plop={selectedPlop}
plant={plantsById.get(selectedPlop.plantId)}
plants={plants}
gardenId={gid}
unit={unit}
canEdit={canEdit}
noteCount={0}
large={isMobile}
onNotes={() => openNotes({ type: 'plop', id: selectedPlop.id })}
onRemoved={() => setSel(null)}
/>
) : null
const journal = (
<JournalTab
gardenId={gid}
canEdit={canEdit}
currentUserId={currentUserId}
isOwner={isOwner}
objects={objects}
plantings={plantings}
attach={attach}
composerFirst={isMobile}
large={isMobile}
/>
)
const assistant = hasAssistant ? <AssistantTab gardenId={gid} canEdit={canEdit} undo={undoLast.undo} large={isMobile} /> : null
const canvas = (
<Canvas ref={canvasRef} garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} letters={letters} canEdit={canEdit} isMobile={isMobile} />
)
const dialogs = (
<>
{scanning && <ScanPacketDialog unit={unit} onClose={() => setScanning(false)} />}
{clearing && focused && (
<ClearBedDialog objectId={focused.id} objectName={objectDisplayName(focused)} plopCount={focusedPlops.length} gardenId={gid} onClose={() => setClearing(false)} />
)}
</>
)
// ── phone ───────────────────────────────────────────────────────────────
if (isMobile) {
const peekInsp = !!inspector
const peekJournal = !inspector && mode === 'journal'
const peekChat = !inspector && mode === 'chat' && hasAssistant
const peek = peekInsp || peekJournal || peekChat
const stripKinds = !peek && mode === 'build'
const stripPlants = !peek && mode === 'plants'
const modes = MODES.filter((m) => m.id !== 'chat' || hasAssistant)
const closePeek = () => {
setSel(null)
if (mode === 'journal' || mode === 'chat') setMode('build')
}
const selectMode = (id: PhoneMode) => {
const st = useEditorStore.getState()
st.setMode(st.mode === id && (id === 'journal' || id === 'chat') ? 'build' : id)
st.setSel(null)
st.setArmedKind(null)
st.setGhost(null)
if (id !== 'plants') st.setArmedPlant(null)
}
const choices = lotChoices(armedPlant, lotsByPlantId)
return (
<div ref={rootRef} className="flex h-dvh flex-col bg-bg">
<div className="flex flex-none items-center gap-2 px-3 py-2.5">
{focused ? (
<IconButton label="Back to the plan" icon="chevron-left" iconSize={17} size={38} onClick={exitFocus} />
) : (
<Link to="/gardens" className="btn btn-icon btn-secondary no-underline" style={{ width: 38, height: 38 }} title="Your gardens" aria-label="Your gardens">
<Icon name="chevron-left" size={17} />
</Link>
)}
<span className="min-w-0 truncate font-heading text-[17px]" title={focused?.name ?? g.name}>
{focused?.name ?? g.name}
</span>
<span className="ml-auto flex flex-none items-center gap-2">
<ThemeButton size={38} iconSize={16} />
{seasons.options.length > 1 && (
<button type="button" className="tag tag-accent-2 cursor-pointer border-0" onClick={seasons.cycle} title="Switch season">
{seasons.short}
</button>
)}
<IconButton label="Undo" icon="undo-2" iconSize={16} size={38} disabled={!canEdit || !undoLast.canUndo} onClick={undoLast.undoLast} />
</span>
</div>
{banner && <div className="flex-none bg-accent-2-200 px-3.5 py-1.5 text-xs font-semibold text-accent-2-800">{banner}</div>}
<div className="relative min-h-0 flex-1">
{canvas}
<IconButton label="Fit the garden" icon="maximize" iconSize={16} variant="plain" size={40} className="elev-sm absolute bottom-3 right-3 border border-divider bg-surface" onClick={() => canvasRef.current?.zoomFit()} />
</div>
{peek && (
<div className="elev-lg flex max-h-[45%] flex-none flex-col rounded-t-[22px] border-t border-divider bg-neutral-100">
<div className="flex items-center gap-2 px-3.5 pb-0.5 pt-2.5">
<span className="font-heading text-[15px]">{peekInsp ? (selectedObject ? 'Selected' : 'Planting') : peekJournal ? 'Journal' : 'Assistant'}</span>
<IconButton label="Close" icon="x" variant="plain" size={32} className="ml-auto" onClick={closePeek} />
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-4 pb-4 pt-2">
{peekInsp && inspector}
{peekJournal && journal}
{peekChat && assistant}
</div>
</div>
)}
{(stripKinds || stripPlants) && (
<div className="flex flex-none flex-col gap-2 border-t border-divider bg-bg px-3 py-2.5">
{stripPlants && choices.length > 0 && <LotChooser lots={choices} value={armedLotId} onChange={setArmedLotId} compact />}
{stripPlants && armedPlant && focused && canEdit && <FillRow plant={armedPlant} busy={fillObject.isPending} onFill={(layout) => fillObject.mutate({ objectId: focused.id, plantId: armedPlant.id, layout })} />}
<div className="flex gap-2 overflow-x-auto">
{!canEdit && <span className="self-center whitespace-nowrap px-1 text-xs text-ink-mute">View only you can look but not change this garden.</span>}
{stripPlants && focused && (
<Button variant="primary" tall className="flex-none" onClick={exitFocus}>
Done
</Button>
)}
{stripKinds &&
canEdit &&
OBJECT_KINDS.map((k) => (
<button key={k.kind} type="button" className={chipClass(armedKind === k.kind)} aria-pressed={armedKind === k.kind} onClick={() => toggleArmedKind(k.kind)}>
<KindSwatch def={k} compact />
<span className="whitespace-nowrap text-[13px] font-bold">{k.label}</span>
</button>
))}
{stripPlants &&
canEdit &&
orderedPlants(plants, plantings, '').map((p) => (
<button key={p.id} type="button" className={chipClass(armedPlant?.id === p.id)} aria-pressed={armedPlant?.id === p.id} onClick={() => toggleArmedPlant(p, lotsByPlantId)}>
<ColorDot color={p.color} size={15} />
<span className="whitespace-nowrap text-[13px] font-bold">{p.name}</span>
</button>
))}
{stripPlants && canEdit && focused && focusedPlops.length > 0 && (
<button type="button" className={cn(chipClass(false), 'text-accent-700')} onClick={() => setClearing(true)}>
<Icon name="eraser" size={14} />
<span className="whitespace-nowrap text-[13px] font-bold">Clear ({focusedPlops.length})</span>
</button>
)}
{stripPlants && canEdit && canScan && (
<button type="button" className={chipClass(false)} onClick={() => setScanning(true)}>
<Icon name="camera" size={14} />
<span className="whitespace-nowrap text-[13px] font-bold">Scan a packet</span>
</button>
)}
</div>
</div>
)}
<div className="flex flex-none gap-1 border-t border-divider bg-surface px-2 pb-[calc(6px+env(safe-area-inset-bottom))] pt-1.5" role="tablist" aria-label="Editor mode">
{modes.map((m) => {
const active = mode === m.id && !inspector
return (
<button
key={m.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => selectMode(m.id)}
className={cn('flex min-h-[52px] flex-1 flex-col items-center gap-[3px] rounded-[16px] border-0 px-1 py-2', active ? 'bg-accent-200 text-accent-800' : 'text-ink-soft')}
>
<Icon name={m.icon} size={19} />
<span className="text-[11px] font-bold">{m.label}</span>
</button>
)
})}
</div>
{dialogs}
</div>
)
}
// ── desktop ─────────────────────────────────────────────────────────────
const tabs = TABS.filter((t) => t.id !== 'chat' || hasAssistant)
const plot = inspector ?? <GardenSummary objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
const toolkit = (
<Toolkit
unit={unit}
plants={plants}
plantings={plantings}
lotsByPlant={lotsByPlantId}
canEdit={canEdit}
focused={focused}
focusedPlopCount={focusedPlops.length}
canScan={canScan}
filling={fillObject.isPending}
onBack={exitFocus}
onFill={(layout) => focused && armedPlant && fillObject.mutate({ objectId: focused.id, plantId: armedPlant.id, layout })}
onClear={() => setClearing(true)}
onScan={() => setScanning(true)}
/>
)
return (
<div ref={rootRef} className="flex h-dvh flex-col bg-bg">
<Nav active="gardens" />
<div className="grid min-h-0 flex-1 gap-3.5 p-3.5 pt-0 [grid-template-columns:minmax(0,1fr)_400px]">
<div className="panel flex min-h-0 flex-col overflow-hidden">
<div className="flex flex-wrap items-center gap-3 border-b border-divider px-[18px] py-3">
<h4 className="text-[19px]">{g.name}</h4>
<span className="text-[13px] font-semibold text-ink-mute">{formatSize(g.widthCm, g.heightCm, unit)}</span>
{focused && (
<>
<span className="opacity-40">/</span>
<span className="text-sm font-bold text-accent-700">{focused.name}</span>
</>
)}
<span className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2">
{seasons.options.length > 1 && <Seg options={seasons.options} value={seasons.value} onChange={seasons.select} label="Season" optionClassName="px-[13px]" />}
<Button icon="undo-2" className="gap-[7px]" disabled={!canEdit || !undoLast.canUndo} onClick={undoLast.undoLast} title={undoLast.target ? `Undo: ${undoLast.target.summary}` : 'Nothing to undo'}>
Undo
</Button>
</span>
</div>
{banner && <div className="bg-accent-2-200 px-[18px] py-2 text-[13px] font-semibold text-accent-2-800">{banner}</div>}
<div className="relative min-h-0 flex-1">
{canvas}
<div className="elev-sm absolute bottom-3 right-3 flex gap-1 rounded-full border border-divider bg-surface p-1">
<IconButton label="Zoom out" icon="minus" iconSize={14} variant="plain" size={30} onClick={() => canvasRef.current?.zoomOut()} />
<IconButton label="Fit" icon="maximize" iconSize={14} variant="plain" size={30} onClick={() => canvasRef.current?.zoomFit()} />
<IconButton label="Zoom in" icon="plus" iconSize={14} variant="plain" size={30} onClick={() => canvasRef.current?.zoomIn()} />
</div>
</div>
</div>
<div className="panel flex min-h-0 flex-col overflow-hidden">
<div className="flex gap-1 px-2.5 pt-2.5" role="tablist">
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={cn('flex-1 rounded-full border-0 px-1 py-2 text-[12.5px] font-bold', tab === t.id ? 'bg-accent-200 text-accent-800' : 'text-ink-soft hover:text-text')}
>
{t.label}
</button>
))}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4">
{tab === 'toolkit' && toolkit}
{tab === 'plot' && plot}
{tab === 'journal' && journal}
{tab === 'history' && <HistoryTab canEdit={canEdit} undoLast={undoLast} />}
{tab === 'chat' && assistant}
</div>
</div>
</div>
{dialogs}
</div>
)
}
function chipClass(armed: boolean): string {
return cn(
'flex min-h-11 flex-none items-center gap-2 rounded-full border px-3.5 py-2.5',
armed ? 'border-accent-400 bg-accent-200' : 'border-divider bg-neutral-100',
)
}