From 2d25b7e28e016b2ec6c1fcbf8df2d040c65a336e Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 01:31:03 -0400 Subject: [PATCH] =?UTF-8?q?Address=20editor-mode=20review:=20mode=E2=86=94?= =?UTF-8?q?focus=E2=86=94rail=20syncing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- web/src/editor/store.ts | 7 +- web/src/pages/GardenEditorPage.tsx | 199 ++++++++++++++++++++--------- 2 files changed, 142 insertions(+), 64 deletions(-) diff --git a/web/src/editor/store.ts b/web/src/editor/store.ts index 1992f5d..5bc824a 100644 --- a/web/src/editor/store.ts +++ b/web/src/editor/store.ts @@ -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. 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 @@ -94,7 +97,7 @@ export const useEditorStore = create((set) => ({ viewport: { tx: 0, ty: 0, scale: 1 }, setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })), - mode: 'fixtures', + mode: DEFAULT_MODE, setMode: (mode) => set({ mode }), selectedId: null, @@ -144,6 +147,6 @@ export const useEditorStore = create((set) => ({ railTab: null, seasonYear: null, journalObjectId: null, - mode: 'fixtures', + mode: DEFAULT_MODE, }), })) diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index e9718b4..5816421 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -172,11 +172,18 @@ export function GardenEditorPage() { } }, [focusedObjectId, objects, full.data, setFocusedObject]) - // Focusing a bed IS "placing plants", so the mobile mode bar follows into Plants - // — otherwise the bottom strip would show the object palette while you're inside - // a bed. (Inert on desktop, where the mode bar isn't shown.) + // 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(() => { - 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]) // 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 // 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) @@ -199,7 +215,8 @@ export function GardenEditorPage() { // 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). + // 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') { @@ -208,11 +225,21 @@ export function GardenEditorPage() { } else if (m === 'assistant') { setRailTab('chat') } else { - const rt = useEditorStore.getState().railTab - if (rt === 'journal' || rt === 'chat') setRailTab(null) + 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) { @@ -544,29 +571,16 @@ export function GardenEditorPage() { {objectDisplayName(focusedObject)} {canEdit && (focusedObject.plantable ? ( - <> - setPicker('place')} - /> - {armedPlant && ( - - )} - {focusedPlops.length > 0 && ( - - )} - + setPicker('place')} + onDisarm={() => setArmedPlant(null)} + focusedPlopCount={focusedPlops.length} + onClear={() => setClearing(true)} + /> ) : ( Not plantable ))} @@ -593,39 +607,41 @@ export function GardenEditorPage() {
{mode === 'fixtures' && } {mode === 'plants' && - (focusedObject?.plantable ? ( -
- setPicker('place')} - /> - {armedPlant && ( - +
+ ) : ( + // 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. + + - )} - {focusedPlops.length > 0 && ( - - )} - -
+
+ ) ) : (

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

))} )} - + {railTab && ( @@ -642,7 +658,10 @@ export function GardenEditorPage() { select(null) selectPlanting(null) } 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) }} @@ -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 ( + <> + + {armedPlant && ( + + )} + {focusedPlopCount > 0 && ( + + )} + + ) +} + // 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. @@ -686,12 +753,20 @@ function ModeBar({ mode, onSelect, hasAssistant, + canEdit, }: { mode: EditorMode onSelect: (m: EditorMode) => void 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 (