From 9ec626302b5c949fd7622e82c616a311e67a53e6 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 01:16:44 -0400 Subject: [PATCH] Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone the editor stacked a control column (title, share, season, a 7-chip palette wrapping to 3 rows, journal/history/assistant) ABOVE the canvas, crushing the garden — the point of the app — into a short strip. There was no single "mode" switch: fixtures lived in the palette, plants in a floating focus toolbar, and journal/assistant in the rail. Mobile-first now (#99): the canvas is the whole screen, and a bottom mode bar switches the tools docked beneath it — - Fixtures → the object palette (arm → tap to place) - Plants → the seed tray, once a bed is focused; focusing a bed enters this mode. No bed focused → a "tap a bed, then Plant here" hint. Done planting / Clear ride along. - Journal → opens the journal rail sheet - Assistant → opens the chat rail sheet (hidden with no model configured) A slim mobile top strip keeps the garden name / season / share that lived in the desktop column. Closing a panel rail returns to a canvas mode so the bar reappears; History stays a rail sub-tab (not a fifth mode). Desktop is untouched: the mode bar is md:hidden and the side-column layout stands; `mode` is an inert hint there. Store gains `mode`/`setMode` (reset with the rest of transient state on garden switch). Verified live at 390px (each mode + the select-bed → Plant here → seed-tray flow, canvas now dominant) and 1280px (desktop unchanged). tsc + vitest (92) + build green. DESIGN.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- DESIGN.md | 3 +- web/src/editor/store.ts | 16 +++ web/src/pages/GardenEditorPage.tsx | 155 ++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 5 deletions(-) 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..1992f5d 100644 --- a/web/src/editor/store.ts +++ b/web/src/editor/store.ts @@ -11,10 +11,22 @@ 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' + 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 +94,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: 'fixtures', + setMode: (mode) => set({ mode }), + selectedId: null, select: (id) => set({ selectedId: id, selectedPlantingId: null }), @@ -129,5 +144,6 @@ export const useEditorStore = create((set) => ({ railTab: null, seasonYear: null, journalObjectId: null, + mode: 'fixtures', }), })) diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index 53aacad..e9718b4 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,6 +172,13 @@ 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.) + useEffect(() => { + if (focusedObjectId != null) setMode('plants') + }, [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 @@ -187,6 +197,22 @@ 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). + const selectMode = (m: EditorMode) => { + setMode(m) + if (m === 'journal') { + setJournalObjectId(null) + setRailTab('journal') + } else if (m === 'assistant') { + setRailTab('chat') + } else { + const rt = useEditorStore.getState().railTab + if (rt === 'journal' || rt === 'chat') setRailTab(null) + } + } + // Escape peels back one layer: stop placing → deselect plop → exit focus → deselect. useEffect(() => { function onKey(e: KeyboardEvent) { @@ -439,7 +465,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,9 +515,29 @@ 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 && ( -
+
@@ -536,6 +585,49 @@ 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?.plantable ? ( +
+ setPicker('place')} + /> + {armedPlant && ( + + )} + {focusedPlops.length > 0 && ( + + )} + +
+ ) : ( +

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 { + setMode('fixtures') } setRailTab(null) }} @@ -575,3 +671,54 @@ export function GardenEditorPage() {
) } + +// 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, +}: { + mode: EditorMode + onSelect: (m: EditorMode) => void + hasAssistant: boolean +}) { + const modes = hasAssistant ? MODES : MODES.filter((m) => m.id !== 'assistant') + return ( +
+ {modes.map((m) => { + const active = mode === m.id + return ( + + ) + })} +
+ ) +}