Address editor-mode review: mode↔focus↔rail syncing
Build image / build-and-push (push) Successful in 11s

Gadfly on #99, the real ones — all in the mode/focus/rail interplay:

- (3 models) Closing a journal/assistant rail while a bed was focused
  hard-set mode='fixtures', docking the OBJECT palette inside the focused
  bed with the seed tray unreachable. Derive it: closing a panel returns
  to Plants if still focused, else Fixtures. The canvas-mode effect now
  also follows UN-focus (plants→fixtures) and won't override a panel mode.
- (2 models) Plants mode on a focused non-plantable object (reachable via
  a ?focus= deep link) showed the misleading "tap a bed" hint and no way
  out on mobile. Now it says what's wrong and offers Done (exit focus).
- Selecting an object leaves a panel mode, so closing the inspector can't
  strand the bar on Journal/Assistant with nothing open.
- Tapping Fixtures steps out of a focused bed (you're arranging again).
- Viewer mode bar drops Fixtures/Plants (a viewer can't place anything),
  leaving Journal + Assistant.
- Safety: if the assistant capability flips off live while it's the active
  mode, fall back to a canvas mode and close the orphaned chat rail.

Maintainability: extracted the shared seed-tray + Done + Clear cluster into
PlantPlacementTools (was duplicated between the desktop focus toolbar and
the mobile Plants strip); DEFAULT_MODE const replaces the twice-hardcoded
'fixtures'; selectMode reads the reactive railTab, not getState().

Verified live at 390px: focus a bed → Plants; open journal → close → back
to Plants (tray), not the Fixtures palette; tap Fixtures → exits focus.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-22 01:31:03 -04:00
co-authored by Claude Opus 4.8
parent 9ec626302b
commit 2d25b7e28e
2 changed files with 142 additions and 64 deletions
+5 -2
View File
@@ -18,6 +18,9 @@ export const MAX_SCALE = 20 // px per cm — fully zoomed in
// side-column layout and treats this as a lighter hint. // side-column layout and treats this as a lighter hint.
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant' 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 { interface EditorState {
viewport: Viewport viewport: Viewport
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
@@ -94,7 +97,7 @@ export const useEditorStore = create<EditorState>((set) => ({
viewport: { tx: 0, ty: 0, scale: 1 }, viewport: { tx: 0, ty: 0, scale: 1 },
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })), setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
mode: 'fixtures', mode: DEFAULT_MODE,
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
selectedId: null, selectedId: null,
@@ -144,6 +147,6 @@ export const useEditorStore = create<EditorState>((set) => ({
railTab: null, railTab: null,
seasonYear: null, seasonYear: null,
journalObjectId: null, journalObjectId: null,
mode: 'fixtures', mode: DEFAULT_MODE,
}), }),
})) }))
+137 -62
View File
@@ -172,11 +172,18 @@ export function GardenEditorPage() {
} }
}, [focusedObjectId, objects, full.data, setFocusedObject]) }, [focusedObjectId, objects, full.data, setFocusedObject])
// Focusing a bed IS "placing plants", so the mobile mode bar follows into Plants // The canvas mode follows focus: inside a bed you're placing Plants, out of it
// — otherwise the bottom strip would show the object palette while you're inside // you're arranging Fixtures — so the bottom strip can't show the object palette
// a bed. (Inert on desktop, where the mode bar isn't shown.) // 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(() => { useEffect(() => {
if (focusedObjectId != null) setMode('plants') const cur = useEditorStore.getState().mode
if (focusedObjectId != null) {
if (cur !== 'journal' && cur !== 'assistant') setMode('plants')
} else if (cur === 'plants') {
setMode('fixtures')
}
}, [focusedObjectId, setMode]) }, [focusedObjectId, setMode])
// Selecting anything lands you in the inspector without a click — the rail // Selecting anything lands you in the inspector without a click — the rail
@@ -184,11 +191,20 @@ export function GardenEditorPage() {
// selected ids rather than a "something is selected" boolean, so selecting a // selected ids rather than a "something is selected" boolean, so selecting a
// DIFFERENT object while History is open still brings the inspector forward. // DIFFERENT object while History is open still brings the inspector forward.
// Deselecting drops back out of the inspector but leaves History/Chat open if // 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(() => { useEffect(() => {
if (selectedId != null || selectedPlantingId != null) setRailTab('inspector') if (selectedId != null || selectedPlantingId != null) {
else if (useEditorStore.getState().railTab === 'inspector') setRailTab(null) setRailTab('inspector')
}, [selectedId, selectedPlantingId, setRailTab]) 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 = () => { const exitFocus = () => {
setFocusedObject(null) setFocusedObject(null)
@@ -199,7 +215,8 @@ export function GardenEditorPage() {
// The mobile mode bar. Journal/Assistant are panel modes, so they open the // 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 // rail sheet; Fixtures/Plants are canvas modes, so they close a panel rail (but
// leave an inspector, which is about the selection, alone). // 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) => { const selectMode = (m: EditorMode) => {
setMode(m) setMode(m)
if (m === 'journal') { if (m === 'journal') {
@@ -208,11 +225,21 @@ export function GardenEditorPage() {
} else if (m === 'assistant') { } else if (m === 'assistant') {
setRailTab('chat') setRailTab('chat')
} else { } else {
const rt = useEditorStore.getState().railTab if (railTab === 'journal' || railTab === 'chat') setRailTab(null)
if (rt === 'journal' || rt === '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. // Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -544,29 +571,16 @@ export function GardenEditorPage() {
<span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span> <span className="max-w-[8rem] truncate text-muted">{objectDisplayName(focusedObject)}</span>
{canEdit && {canEdit &&
(focusedObject.plantable ? ( (focusedObject.plantable ? (
<> <PlantPlacementTools
<SeedTray trayPlants={trayPlants}
trayPlants={trayPlants} armedPlant={armedPlant}
armedPlantId={armedPlant?.id ?? null} onArm={armPlant}
onArm={armPlant} onRemove={removeFromTrayAndDisarm}
onRemove={removeFromTrayAndDisarm} onOpenPicker={() => setPicker('place')}
onOpenPicker={() => setPicker('place')} onDisarm={() => setArmedPlant(null)}
/> focusedPlopCount={focusedPlops.length}
{armedPlant && ( onClear={() => setClearing(true)}
<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> <span className="text-xs text-muted">Not plantable</span>
))} ))}
@@ -593,39 +607,41 @@ export function GardenEditorPage() {
<div className="mb-2 min-h-[2.25rem]"> <div className="mb-2 min-h-[2.25rem]">
{mode === 'fixtures' && <Palette />} {mode === 'fixtures' && <Palette />}
{mode === 'plants' && {mode === 'plants' &&
(focusedObject?.plantable ? ( (focusedObject ? (
<div className="flex flex-wrap items-center gap-2"> focusedObject.plantable ? (
<SeedTray <div className="flex flex-wrap items-center gap-2">
trayPlants={trayPlants} <PlantPlacementTools
armedPlantId={armedPlant?.id ?? null} trayPlants={trayPlants}
onArm={armPlant} armedPlant={armedPlant}
onRemove={removeFromTrayAndDisarm} onArm={armPlant}
onOpenPicker={() => setPicker('place')} onRemove={removeFromTrayAndDisarm}
/> onOpenPicker={() => setPicker('place')}
{armedPlant && ( onDisarm={() => setArmedPlant(null)}
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}> focusedPlopCount={focusedPlops.length}
onClear={() => setClearing(true)}
/>
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
Done planting
</Button>
</div>
) : (
// 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).
<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 Done
</Button> </Button>
)} </div>
{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>
)}
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
Done planting
</Button>
</div>
) : ( ) : (
<p className="px-1 text-xs text-muted">Tap a bed, then 🌱 Plant here to start planting.</p> <p className="px-1 text-xs text-muted">Tap a bed, then 🌱 Plant here to start planting.</p>
))} ))}
</div> </div>
)} )}
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} /> <ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} canEdit={canEdit} />
</div> </div>
{railTab && ( {railTab && (
@@ -642,7 +658,10 @@ export function GardenEditorPage() {
select(null) select(null)
selectPlanting(null) selectPlanting(null)
} else { } else {
setMode('fixtures') // 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) setRailTab(null)
}} }}
@@ -672,6 +691,54 @@ export function GardenEditorPage() {
) )
} }
// 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({
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 // The mobile primary mode switch (#99): one always-there tab bar so "placing
// beds", "planting", "journaling" and "assistant" stop competing for the same // beds", "planting", "journaling" and "assistant" stop competing for the same
// strip. Assistant is dropped when the instance has no model configured. // strip. Assistant is dropped when the instance has no model configured.
@@ -686,12 +753,20 @@ function ModeBar({
mode, mode,
onSelect, onSelect,
hasAssistant, hasAssistant,
canEdit,
}: { }: {
mode: EditorMode mode: EditorMode
onSelect: (m: EditorMode) => void onSelect: (m: EditorMode) => void
hasAssistant: boolean hasAssistant: boolean
canEdit: boolean
}) { }) {
const modes = hasAssistant ? MODES : MODES.filter((m) => m.id !== 'assistant') 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 ( return (
<div <div
role="tablist" role="tablist"