diff --git a/DESIGN.md b/DESIGN.md index 27e4209..a5a60d8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/web/src/editor/store.ts b/web/src/editor/store.ts index 1e55f97..5bc824a 100644 --- a/web/src/editor/store.ts +++ b/web/src/editor/store.ts @@ -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((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((set) => ({ railTab: null, seasonYear: null, journalObjectId: null, + mode: DEFAULT_MODE, }), })) diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index 53aacad..5816421 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -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 + // 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) @@ -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 (
-
+ {/* 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}

@@ -486,38 +542,45 @@ export function GardenEditorPage() {
+ {/* 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')} - /> - {armedPlant && ( - - )} - {focusedPlops.length > 0 && ( - - )} - + setPicker('place')} + onDisarm={() => setArmedPlant(null)} + focusedPlopCount={focusedPlops.length} + onClear={() => setClearing(true)} + /> ) : ( Not plantable ))} @@ -536,6 +599,51 @@ export function GardenEditorPage() { )}
+ {/* 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. */} +
+ {canEdit && (mode === 'fixtures' || mode === 'plants') && ( +
+ {mode === 'fixtures' && } + {mode === 'plants' && + (focusedObject ? ( + focusedObject.plantable ? ( +
+ setPicker('place')} + onDisarm={() => setArmedPlant(null)} + focusedPlopCount={focusedPlops.length} + onClear={() => setClearing(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.

+ ))} +
+ )} + +
+ {railTab && ( { // 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) }} @@ -575,3 +690,110 @@ 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. +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 ( + + ) + })} +
+ ) +}