Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 8m55s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m56s

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-22 01:16:44 -04:00
co-authored by Claude Opus 4.8
parent 8a27df9e9c
commit 9ec626302b
3 changed files with 169 additions and 5 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.
+16
View File
@@ -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<EditorState>((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<EditorState>((set) => ({
railTab: null,
seasonYear: null,
journalObjectId: null,
mode: 'fixtures',
}),
}))
+151 -4
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,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 (
<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>
@@ -486,9 +515,29 @@ 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>
@@ -536,6 +585,49 @@ 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?.plantable ? (
<div className="flex flex-wrap items-center gap-2">
<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>
)}
<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>
))}
</div>
)}
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} />
</div>
{railTab && (
<EditorRail
tabs={railTabs}
@@ -543,10 +635,14 @@ 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 {
setMode('fixtures')
}
setRailTab(null)
}}
@@ -575,3 +671,54 @@ export function GardenEditorPage() {
</div>
)
}
// 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 (
<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>
)
}