Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99) #110

Merged
steve merged 2 commits from feat/editor-modes into main 2026-07-22 05:32:47 +00:00
3 changed files with 274 additions and 32 deletions
+2 -1
View File
@@ -128,7 +128,8 @@ Makefile (cd web && npm run build) → copy dist → CGO_ENABLED
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag.
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag, and the mobile `mode`.
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (the seed tray, shown once a bed is focused; focusing a bed puts you in this mode), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
+19
View File
@@ -11,10 +11,25 @@ import type { EditorObject } from './types'
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
export const MAX_SCALE = 20 // px per cm — fully zoomed in
// The editor's one primary activity (#99). On mobile this is the segmented
// control at the bottom of the screen; it decides which tools dock there —
// placing fixtures, placing plants, the journal, or the assistant — so the four
// activities stop competing for the same cramped strip. Desktop keeps its
// side-column layout and treats this as a lighter hint.
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant'
// Where the editor starts, and where it returns on a garden switch.
export const DEFAULT_MODE: EditorMode = 'fixtures'
interface EditorState {
viewport: Viewport
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
// The primary editor mode (mobile mode bar). Ephemeral — which tool you were
// last using is not a property of the garden.
mode: EditorMode
setMode: (mode: EditorMode) => void
// The selected object OR plop (mutually exclusive; selecting one clears the
// other). selectedId is a garden object; selectedPlantingId is a plop.
selectedId: number | null
@@ -82,6 +97,9 @@ export const useEditorStore = create<EditorState>((set) => ({
viewport: { tx: 0, ty: 0, scale: 1 },
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
mode: DEFAULT_MODE,
setMode: (mode) => set({ mode }),
selectedId: null,
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
@@ -129,5 +147,6 @@ export const useEditorStore = create<EditorState>((set) => ({
railTab: null,
Outdated
Review

Default mode 'fixtures' hardcoded twice (initial state and resetTransient)

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **Default mode 'fixtures' hardcoded twice (initial state and resetTransient)** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
seasonYear: null,
journalObjectId: null,
mode: DEFAULT_MODE,
}),
}))
+253 -31
View File
@@ -16,7 +16,8 @@ import { ClearBedModal } from '@/editor/ClearBedModal'
import { EditorHint } from '@/editor/EditorHint'
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
import { objectDisplayName } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store'
import { useEditorStore, type EditorMode } from '@/editor/store'
import { cn } from '@/lib/cn'
import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { useMe } from '@/lib/auth'
@@ -83,6 +84,8 @@ export function GardenEditorPage() {
)
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 updateObject = useUpdateObject(gid)
@@ -169,16 +172,39 @@ export function GardenEditorPage() {
}
}, [focusedObjectId, objects, full.data, setFocusedObject])
// The canvas mode follows focus: inside a bed you're placing Plants, out of it
Outdated
Review

🟡 Misleading comment claims focus effect is inert on desktop

error-handling, maintainability · flagged by 2 models

  • web/src/pages/GardenEditorPage.tsx:175-177 — The comment claims the focus→plants effect is "Inert on desktop". It is not inert: it actively writes to the store on every focus change. The value happens to be unused on desktop, which is different.

🪰 Gadfly · advisory

🟡 **Misleading comment claims focus effect is inert on desktop** _error-handling, maintainability · flagged by 2 models_ - **`web/src/pages/GardenEditorPage.tsx:175-177`** — The comment claims the focus→plants effect is "Inert on desktop". It is not inert: it actively writes to the store on every focus change. The value happens to be unused on desktop, which is different. <sub>🪰 Gadfly · advisory</sub>
// 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.
// 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')
else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null)
}, [selectedId, selectedPlantingId, setRailTab])
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])
const exitFocus = () => {
setFocusedObject(null)
1
@@ -187,6 +213,33 @@ export function GardenEditorPage() {
selectPlanting(null)
}
// The mobile mode bar. Journal/Assistant are panel modes, so they open the
// rail sheet; Fixtures/Plants are canvas modes, so they close a panel rail (but
// leave an inspector, which is about the selection, alone). Tapping Fixtures
// means going back to arranging objects, so it 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 {
if (railTab === 'journal' || railTab === 'chat') 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) {
@@ -439,7 +492,10 @@ export function GardenEditorPage() {
// the canvas bottom + Fit button under the browser chrome (#85).
return (
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
<div className="shrink-0 md:w-40">
{/* 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). */}
<div className="hidden shrink-0 md:block md:w-40">
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name}
</h1>
1
@@ -486,38 +542,45 @@ export function GardenEditorPage() {
</div>
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
{/* Mobile top strip: the garden identity / season / share that live in the
desktop left column. md:hidden. */}
<div className="flex items-center gap-2 md:hidden">
<h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight" title={garden.name}>
{garden.name}
</h1>
{!canEdit && seasonYear === null && (
<span className="shrink-0 rounded-md bg-border/40 px-2 py-0.5 text-xs text-muted">👁 View only</span>
)}
{years.data && years.data.length > 0 && (
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
)}
{isOwner && (
<Button variant="ghost" className="shrink-0 px-2 py-1 text-xs" onClick={() => setSharing(true)}>
Share
</Button>
)}
</div>
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => 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 && (
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
<div className="absolute left-2 top-2 z-20 hidden flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur md:flex">
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
{garden.name}
</button>
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
{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>
)}
</>
<PlantPlacementTools
trayPlants={trayPlants}
armedPlant={armedPlant}
onArm={armPlant}
onRemove={removeFromTrayAndDisarm}
onOpenPicker={() => setPicker('place')}
onDisarm={() => setArmedPlant(null)}
focusedPlopCount={focusedPlops.length}
onClear={() => setClearing(true)}
/>
) : (
<span className="text-xs text-muted">Not plantable</span>
))}
2
@@ -536,6 +599,51 @@ export function GardenEditorPage() {
)}
</div>
{/* Mobile bottom: contextual tools for the current mode + the mode switch
bar (#99). md:hidden — desktop uses the left column. A panel-mode rail
(journal/assistant) or the inspector overlays this while open. */}
<div className="shrink-0 md:hidden">
{canEdit && (mode === 'fixtures' || mode === 'plants') && (
<div className="mb-2 min-h-[2.25rem]">
{mode === 'fixtures' && <Palette />}
{mode === 'plants' &&
(focusedObject ? (
focusedObject.plantable ? (
<div className="flex flex-wrap items-center gap-2">
<PlantPlacementTools
trayPlants={trayPlants}
armedPlant={armedPlant}
onArm={armPlant}
onRemove={removeFromTrayAndDisarm}
onOpenPicker={() => setPicker('place')}
onDisarm={() => setArmedPlant(null)}
focusedPlopCount={focusedPlops.length}
onClear={() => setClearing(true)}
/>
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
Done planting
Outdated
Review

🟡 Mobile Plants-mode hint says 'tap a bed' even when a non-plantable object is already focused (via deep-linked ?focus=), unlike the desktop toolbar's explicit 'Not plantable' state

correctness · flagged by 2 models

  • web/src/pages/GardenEditorPage.tsx:624 — Plants mode on a focused non-plantable object shows a misleading hint and no exit. Verified: focusing any object triggers the effect at :178mode='plants'. The Inspector only exposes the Focus button for object.plantable (Inspector.tsx:139), so in practice users can't focus a non-plantable object from the inspector — but a ?focus=<id> deep link (adopted at :135) to a non-plantable object (e.g. a shed) sets focusedObjectId directly,…

🪰 Gadfly · advisory

🟡 **Mobile Plants-mode hint says 'tap a bed' even when a non-plantable object is already focused (via deep-linked ?focus=), unlike the desktop toolbar's explicit 'Not plantable' state** _correctness · flagged by 2 models_ - **`web/src/pages/GardenEditorPage.tsx:624` — Plants mode on a focused non-plantable object shows a misleading hint and no exit.** Verified: focusing any object triggers the effect at `:178` → `mode='plants'`. The Inspector only exposes the Focus button for `object.plantable` (`Inspector.tsx:139`), so in practice users can't focus a non-plantable object from the inspector — but a `?focus=<id>` deep link (adopted at `:135`) to a non-plantable object (e.g. a shed) sets `focusedObjectId` directly,… <sub>🪰 Gadfly · advisory</sub>
</Button>
</div>
) : (
// Reachable via a ?focus= deep link onto a non-plantable object:
Outdated
Review

🟡 mode can stay stuck on 'assistant' with no active tab/panel if the agent capability flips off live

error-handling · flagged by 1 model

🪰 Gadfly · advisory

🟡 **mode can stay stuck on 'assistant' with no active tab/panel if the agent capability flips off live** _error-handling · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
// say so, and give a way back out (there's no focus toolbar here).
<div className="flex items-center gap-2">
<span className="px-1 text-xs text-muted">
{objectDisplayName(focusedObject)} isnt plantable.
</span>
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
Done
</Button>
</div>
)
) : (
<p className="px-1 text-xs text-muted">Tap a bed, then 🌱 Plant here to start planting.</p>
))}
</div>
)}
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} canEdit={canEdit} />
</div>
Review

🔴 Closing Journal/Assistant/History rail forces mode back to 'fixtures' even while a bed is still focused, contradicting the focus→plants invariant and hiding the seed tray behind the object palette

correctness, error-handling · flagged by 5 models

  • web/src/pages/GardenEditorPage.tsx:645 — Closing a panel rail (journal/history/chat) unconditionally calls setMode('fixtures'), which can violate the "focusing a bed puts you in Plants mode" invariant established at line 178-180. Reachable path: focus a bed (mode→plants via the effect) → tap Journal mode (selectMode('journal') opens the rail, mode=journal) → close the rail. The close handler sets mode back to fixtures while focusedObjectId is still set, so the mobile bottom s…

🪰 Gadfly · advisory

🔴 **Closing Journal/Assistant/History rail forces mode back to 'fixtures' even while a bed is still focused, contradicting the focus→plants invariant and hiding the seed tray behind the object palette** _correctness, error-handling · flagged by 5 models_ - `web/src/pages/GardenEditorPage.tsx:645` — Closing a panel rail (journal/history/chat) unconditionally calls `setMode('fixtures')`, which can violate the "focusing a bed puts you in Plants mode" invariant established at line 178-180. Reachable path: focus a bed (mode→`plants` via the effect) → tap **Journal** mode (`selectMode('journal')` opens the rail, mode=`journal`) → close the rail. The close handler sets mode back to `fixtures` while `focusedObjectId` is still set, so the mobile bottom s… <sub>🪰 Gadfly · advisory</sub>
{railTab && (
<EditorRail
tabs={railTabs}
@@ -543,10 +651,17 @@ export function GardenEditorPage() {
onActivate={setRailTab}
onClose={() => {
// Only the inspector is *about* the selection, so only closing it
// deselects; dismissing History leaves the canvas as you had it.
// deselects; dismissing a panel leaves the canvas as you had it. Any
// panel rail (journal/history/chat) drops back to a canvas mode so the
// mobile mode bar reappears.
if (railTab === 'inspector') {
select(null)
selectPlanting(null)
} else {
// Back to a canvas mode so the mode bar reappears — 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)
}}
1
@@ -575,3 +690,110 @@ export function GardenEditorPage() {
</div>
)
}
// The plant-placement cluster (seed tray + Done + Clear), shared by the desktop
Outdated
Review

🟡 ModeBar can have no active tab when assistant capability is removed

correctness · flagged by 1 model

  • web/src/pages/GardenEditorPage.tsx:694 — When hasAssistant flips from true to false (e.g. capabilities query refreshes after an admin disables the agent), the ModeBar filters out the assistant tab, but mode may still be 'assistant'. No tab will be highlighted as active, leaving the mode bar in an inconsistent state. Fix: either normalize mode to 'fixtures' when the current mode is no longer in the filtered list, or keep the assistant tab disabled rather than removing it.

🪰 Gadfly · advisory

🟡 **ModeBar can have no active tab when assistant capability is removed** _correctness · flagged by 1 model_ * `web/src/pages/GardenEditorPage.tsx:694` — When `hasAssistant` flips from `true` to `false` (e.g. capabilities query refreshes after an admin disables the agent), the `ModeBar` filters out the assistant tab, but `mode` may still be `'assistant'`. No tab will be highlighted as active, leaving the mode bar in an inconsistent state. **Fix:** either normalize `mode` to `'fixtures'` when the current mode is no longer in the filtered list, or keep the assistant tab disabled rather than removing it. <sub>🪰 Gadfly · advisory</sub>
// focus toolbar and the mobile Plants strip so the two can't drift apart.
function PlantPlacementTools({
trayPlants,
armedPlant,
onArm,
onRemove,
onOpenPicker,
onDisarm,
focusedPlopCount,
onClear,
}: {
trayPlants: Plant[]
armedPlant: Plant | null
onArm: (p: Plant, lot?: SeedLot) => void
onRemove: (id: number) => void
onOpenPicker: () => void
onDisarm: () => void
focusedPlopCount: number
onClear: () => void
}) {
return (
<>
<SeedTray
trayPlants={trayPlants}
armedPlantId={armedPlant?.id ?? null}
onArm={onArm}
onRemove={onRemove}
onOpenPicker={onOpenPicker}
/>
{armedPlant && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
Done
</Button>
)}
{focusedPlopCount > 0 && (
<Button
variant="ghost"
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
onClick={onClear}
>
Clear ({focusedPlopCount})
</Button>
)}
</>
)
}
// 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 (
<div
role="tablist"
aria-label="Editor mode"
className="flex items-stretch justify-around overflow-hidden rounded-xl border border-border bg-surface"
>
{modes.map((m) => {
const active = mode === m.id
return (
<button
key={m.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => onSelect(m.id)}
className={cn(
'flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors',
active ? 'bg-border/60 text-accent-strong' : 'text-muted hover:text-fg',
)}
>
<span aria-hidden className="text-lg leading-none">
{m.icon}
</span>
{m.label}
</button>
)
})}
</div>
)
}