diff --git a/CLAUDE.md b/CLAUDE.md index c8093ae..b103382 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,11 @@ Conventions that follow from it: - **The editor's breakpoint is container width** (`PHONE_BREAKPOINT` = 760 in `web/src/editor/shared.ts`, measured with a ResizeObserver), not a media query. One component tree, two chromes; don't build a second page. +- **The desktop toolkit is a rail tab, not the handoff's left card** (Steve's + call, 2026-08-23: the card's width was better spent on the plan and the + rail). `Toolkit` renders `embedded` inside the rail as its first tab; + focusing a bed (double-click) switches the rail to it, a single click + selects into Plot. Don't bring the third column back. - **Plant markers are monograms** derived from the name (`web/src/lib/monogram.ts`); the collision set is the whole catalog so the letters match everywhere. `plant.icon` still exists in the API but nothing renders it. diff --git a/DESIGN.md b/DESIGN.md index 7231cc9..028e9db 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -132,7 +132,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac - **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog), `/settings` (admin), `/g/:token` (public read-only). Auth guard on the router root via `/auth/me`. Every page renders its own nav; the root shell is only a Suspense boundary plus the toast stack. - **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One Zustand store (`web/src/editor/store.ts`) for ephemeral editor state only: camera `{tx, ty, s}`, selection, focused bed, the armed kind or plant (+ seed lot), rail tab, phone mode, journal scope, in-flight drag geometry. - **The canvas (`web/src/editor/Canvas.tsx`)** is one SVG with one `translate/scale` group and its own pointer-event model (above). Object drags snap their center to a 3″ grid (the garden's own grid when it snaps) and commit ONE PATCH on release; plops drag in their bed's local frame and may overhang its edge by half their radius (the spacing rule). Fit and focus animate through a CSS transition on the group; drags and zooms don't. Corner handles on the selected object resize it — the one addition to the prototype, since the kinds' fixed default sizes can't make a 2′×8′ bed. Tap-to-place makes a one-plant plop (radius = spacing/2); "Fill the bed" (rows or clumps, `POST /objects/:id/fill`) is how you plant in bulk. -- **Two chromes, one tree.** The editor measures its own container: ≥ 760px is the desktop workspace (toolkit 216px | plan | rail 336px, the rail's Plot/Journal/History/Assistant tabs); below it the phone layout — header, full-screen canvas, an in-flow **peek** (≤ 45% tall, docked between the canvas and the mode bar) for the inspector, journal or assistant, a tool strip for Build or Plants mode, and the always-visible mode bar. Focusing a bed on the phone switches to Plants mode; a plant can also be tapped straight into any bed without focusing. +- **Two chromes, one tree.** The editor measures its own container: ≥ 760px is the desktop workspace (plan | rail 400px, the rail's Toolkit/Plot/Journal/History/Assistant tabs — the toolkit was the handoff's 216px left card until 2026-08-23, and folding it into the rail gave the plan and the rail the width; focusing a bed switches the rail to Toolkit, selecting one switches it to Plot); below it the phone layout — header, full-screen canvas, an in-flow **peek** (≤ 45% tall, docked between the canvas and the mode bar) for the inspector, journal or assistant, a tool strip for Build or Plants mode, and the always-visible mode bar. Focusing a bed on the phone switches to Plants mode; a plant can also be tapped straight into any bed without focusing. - **Plant markers** are the plant's color plus a monogram derived from its name (`web/src/lib/monogram.ts`, collisions resolved across the whole catalog so a letter means the same thing on every screen). - **Undo** in the editor's header reverts the newest change set still in effect (not already reverted, not itself a revert) — after re-reading the history, because the cached list trails the canvas right after a placement, and undoing the step *before* the one you meant is the worst thing an undo button can do. The History tab offers every step, including undoing an undo. - **Seasons** are the years with planting data (`GET /gardens/:id/years`); the current year is live, any other is read-only. A *plan* is a whole-garden copy named `` (`web/src/lib/plan.ts`); the season control lists a garden's plan copies and, from inside one, the way back. The name is the only link the API keeps, which is deliberate — rename the copy and it is simply a garden. diff --git a/web/src/editor/Canvas.tsx b/web/src/editor/Canvas.tsx index 3ff9058..1525af0 100644 --- a/web/src/editor/Canvas.tsx +++ b/web/src/editor/Canvas.tsx @@ -177,7 +177,8 @@ export const Canvas = forwardRef< st.setSel(m ? null : { type: 'object', id: o.id }) st.setArmedKind(null) st.setGhost(null) - st.setTab('plot') + // Focusing is planting intent: the rail shows the toolkit's plant palette. + st.setTab('toolkit') if (!el) return const bw = o.rotationDeg % 180 ? o.heightCm : o.widthCm const bh = o.rotationDeg % 180 ? o.widthCm : o.heightCm diff --git a/web/src/editor/Toolkit.tsx b/web/src/editor/Toolkit.tsx index 13027dd..87bfcd7 100644 --- a/web/src/editor/Toolkit.tsx +++ b/web/src/editor/Toolkit.tsx @@ -14,10 +14,16 @@ import { useEditorStore } from './store' import type { EditorObject } from './types' /** - * The desktop editor's left card. Out of focus: the seven object kinds as pill - * rows — click to arm (then click the plan), or drag one straight onto it. In a - * focused bed it swaps to the plant list with a search, same arm/drag behavior, - * plus the bed's bulk tools (fill, clear, scan a packet). + * The desktop editor's toolkit, the rail's first tab. Out of focus: the seven + * object kinds as pill rows — click to arm (then click the plan), or drag one + * straight onto it. In a focused bed it swaps to the plant list with a search, + * same arm/drag behavior, plus the bed's bulk tools (fill, clear, scan a + * packet). + * + * It started life as the workspace's left card (the handoff's layout) and + * moved into the rail so the plan and the rail get the width the card took; + * the rail's tab body provides the card chrome and the scrolling, and the tab + * is the heading. */ export function Toolkit({ unit, @@ -55,10 +61,9 @@ export function Toolkit({ const [query, setQuery] = useState('') return ( -
+
{!focused ? ( <> -
Toolkit
{OBJECT_KINDS.map((k) => { const armed = armedKind === k.kind return ( diff --git a/web/src/editor/store.ts b/web/src/editor/store.ts index 9f3dd40..c18806d 100644 --- a/web/src/editor/store.ts +++ b/web/src/editor/store.ts @@ -17,7 +17,7 @@ export interface Viewport { export type Selection = { type: 'object'; id: number } | { type: 'plop'; id: number } /** The right-hand rail's tabs (desktop). */ -export type RailTab = 'plot' | 'journal' | 'history' | 'chat' +export type RailTab = 'toolkit' | 'plot' | 'journal' | 'history' | 'chat' /** The phone's primary mode — which tools dock under the canvas. */ export type PhoneMode = 'build' | 'plants' | 'journal' | 'chat' @@ -85,7 +85,7 @@ const TRANSIENT = { armedLotId: null, ghost: null, seasonYear: null, - tab: 'plot' as RailTab, + tab: 'plot' as RailTab, // the garden summary, not the first tab: a fresh editor shows what is there before how to add to it mode: 'build' as PhoneMode, journalScope: null, liveObject: null, diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index d5cadc3..116efa0 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -116,6 +116,7 @@ const MODES: { id: PhoneMode; label: string; icon: IconName }[] = [ ] const TABS: { id: RailTab; label: string }[] = [ + { id: 'toolkit', label: 'Toolkit' }, { id: 'plot', label: 'Plot' }, { id: 'journal', label: 'Journal' }, { id: 'history', label: 'History' }, @@ -124,9 +125,11 @@ const TABS: { id: RailTab; label: string }[] = [ /** * The editor: one canvas, two chromes. Above 760px of container width it is the - * three-card workspace — toolkit, plan, rail; below, a phone layout with the - * canvas as the whole screen, a peek panel that docks between canvas and mode - * bar, and a tool strip for the current mode. Same state, same components. + * two-card workspace — the plan, and a rail whose first tab is the toolkit + * (the handoff drew it as a third card on the left; folding it into the rail + * gave the plan and the rail its width); below, a phone layout with the canvas + * as the whole screen, a peek panel that docks between canvas and mode bar, + * and a tool strip for the current mode. Same state, same components. */ function Editor({ gid, @@ -580,27 +583,28 @@ function Editor({ // ── desktop ───────────────────────────────────────────────────────────── const tabs = TABS.filter((t) => t.id !== 'chat' || hasAssistant) const plot = inspector ?? + const toolkit = ( + focused && armedPlant && fillObject.mutate({ objectId: focused.id, plantId: armedPlant.id, layout })} + onClear={() => setClearing(true)} + onScan={() => setScanning(true)} + /> + ) return (