Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
Build image / build-and-push (push) Successful in 31s
Gadfly review (reusable) / review (pull_request) Failing after 1s
Adversarial Review (Gadfly) / review (pull_request) Failing after 1s

The frontend is rebuilt screen by screen from the handoff: warm cream ground,
terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same
React/Vite/TanStack stack and the same lib/ data layer; the presentation is new.

- Tokens: web/src/styles/index.css declares the handoff's styles.css variables
  through Tailwind's @theme under the same names; dark mode is those variables
  overridden on <html> by the handoff's pansy-theme.js, inlined in index.html
  so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit
  (Button, Dialog, Field, Seg, Toggle, Tag, toast).
- Login / Register: the centered column over soft accent circles; OIDC button
  and signup footer still follow /auth/providers.
- Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots
  from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open +
  share/copy/edit/delete; New garden / Share / Plan-a-season dialogs.
- Plants: monogram markers derived from the name (collision-resolved across the
  catalog — replaces emoji icons), category chips, expandable lot cards, the
  scan-packet flow as a two-step dialog that never auto-creates.
- Settings: Appearance (theme seg), Who gets in (read-only sign-in config),
  Garden assistant (self-saving toggle + chat/vision model fields), You.
- Editor: a new canvas with the prototype's pointer model (wheel-to-cursor,
  pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom
  monograms/labels), plus corner resize handles; desktop three-card workspace
  (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px
  of container width, the phone chrome (header, peek panel, tool strip, mode
  bar). Seasons as a segmented control over the years with data plus plan
  copies; Undo re-reads history before reverting the newest step.
- Public read-only view and the register page restyled to match.
- GET /settings gains a read-only `auth` view (registration mode, local auth,
  OIDC issuer) so the Settings page can show what's in force.
- README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-22 19:12:29 -04:00
co-authored by Claude Fable 5
parent 18b36870d4
commit 52b2c09a9e
111 changed files with 6621 additions and 7215 deletions
+90 -138
View File
@@ -1,163 +1,115 @@
import { create } from 'zustand'
import type { Viewport } from '@/lib/geometry'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import type { EditorObject } from './types'
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
// selection, the object being live-edited mid-gesture, and the palette kind
// armed for tap-to-place. All server state stays in react-query.
// Ephemeral editor state only (DESIGN § State): the camera, the selection, the
// focused bed, what's armed for placement, the rail tab / phone mode, and any
// in-flight drag geometry. All server state stays in react-query.
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
export const MAX_SCALE = 20 // px per cm — fully zoomed in
/** The camera: world (garden cm) → screen is translate(tx,ty) scale(s). */
export interface Viewport {
tx: number
ty: number
s: number
}
// 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'
export type Selection = { type: 'object'; id: number } | { type: 'plop'; id: number }
// Where the editor starts, and where it returns on a garden switch.
export const DEFAULT_MODE: EditorMode = 'fixtures'
/** The right-hand rail's tabs (desktop). */
export type RailTab = 'plot' | 'journal' | 'history' | 'chat'
/** The phone's primary mode — which tools dock under the canvas. */
export type PhoneMode = 'build' | 'plants' | 'journal' | 'chat'
/** Which thing the journal list is narrowed to, when it is. The composer
* attaches to the selection regardless; this is the reading filter. */
export type JournalScope = { type: 'object'; id: number } | { type: 'plop'; id: number }
interface EditorState {
viewport: Viewport
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
vp: Viewport
/** True while the camera is animating (fit/focus); drags and zooms clear it. */
anim: boolean
setVp: (vp: Viewport, anim: boolean) => void
setAnim: (anim: boolean) => 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
sel: Selection | null
setSel: (sel: Selection | null) => 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
select: (id: number | null) => void
focusId: number | null
setFocus: (id: number | null) => void
selectedPlantingId: number | null
selectPlanting: (id: number | null) => void
focusedObjectId: number | null
setFocusedObject: (id: number | null) => void
// Which rail tab is showing, or null when the rail is closed. Selecting an
// object switches this to 'inspector' (see GardenEditorPage), so editing never
// starts with a click on the rail itself.
railTab: string | null
setRailTab: (tab: string | null) => void
// Which season the canvas is showing: null is "now" (live and editable), a
// year is a read-only view of what was in the ground that year. Ephemeral —
// which year you were last looking at is not a property of the garden.
seasonYear: number | null
setSeasonYear: (year: number | null) => void
// Which bed the journal tab is filtered to, or null for the whole garden.
// Separate from selectedId deliberately: you can scope the journal to a bed
// and then select something else without the list moving under you.
journalObjectId: number | null
setJournalObjectId: (id: number | null) => void
// Which single plop the journal is filtered to, if any — the parallel of
// journalObjectId for a planting (#85). The two scopes are mutually exclusive
// (the setters clear each other), so the journal filter is never ambiguous.
journalPlantingId: number | null
setJournalPlantingId: (id: number | null) => void
// The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null
// Which seed lot placements should be attributed to, when the armed plant has
// one worth naming. Cleared with the plant.
armedLotId: number | null
setArmedPlant: (p: Plant | null, lotId?: number | null) => void
// During a move/resize/rotate, the object's live geometry is held here so the
// canvas renders it instantly; the PATCH fires only on gesture end.
liveObject: EditorObject | null
setLiveObject: (o: EditorObject | null) => void
// The same, for a plop being moved/resized.
livePlanting: EditorPlanting | null
setLivePlanting: (p: EditorPlanting | null) => void
// The palette kind armed for tap-to-place (mobile-friendly; also set while
// dragging a kind from the palette). null when nothing is armed.
armedKind: string | null
setArmedKind: (kind: string | null) => void
// True while an object move/resize/rotate is in progress, so the viewport's
// pan gesture stands down (both listen on the same svg).
objectDragging: boolean
setObjectDragging: (v: boolean) => void
armedPlant: Plant | null
/** Which seed lot placements are attributed to, when one is worth naming. */
armedLotId: number | null
setArmedPlant: (plant: Plant | null, lotId?: number | null) => void
setArmedLotId: (lotId: number | null) => void
// Clear all transient (non-persisted) editor state at once — selection, focus,
// armed plant/kind, and any in-flight live geometry — when entering a garden
// view fresh (e.g. the public read-only page on mount). The viewport is left
// alone; the canvas re-fits it from the loaded garden.
resetTransient: () => void
/** Ghost preview position (world cm, snapped) while a kind is armed. */
ghost: { x: number; y: number } | null
setGhost: (g: { x: number; y: number } | null) => void
/** null = now (live, editable); a year = that season, read-only. */
seasonYear: number | null
setSeasonYear: (year: number | null) => void
tab: RailTab
setTab: (tab: RailTab) => void
mode: PhoneMode
setMode: (mode: PhoneMode) => void
journalScope: JournalScope | null
setJournalScope: (scope: JournalScope | null) => void
// During a drag/resize the live geometry is held here so the canvas renders
// it instantly; the PATCH fires once on release.
liveObject: EditorObject | null
setLiveObject: (o: EditorObject | null) => void
livePlanting: EditorPlanting | null
setLivePlanting: (p: EditorPlanting | null) => void
/** Everything transient, cleared when entering or leaving a garden. The
* camera is left alone; the canvas re-fits from the loaded garden. */
reset: () => void
}
const TRANSIENT = {
sel: null,
focusId: null,
armedKind: null,
armedPlant: null,
armedLotId: null,
ghost: null,
seasonYear: null,
tab: 'plot' as RailTab,
mode: 'build' as PhoneMode,
journalScope: null,
liveObject: null,
livePlanting: null,
}
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 })),
vp: { tx: 40, ty: 40, s: 0.5 },
anim: false,
setVp: (vp, anim) => set({ vp, anim }),
setAnim: (anim) => set({ anim }),
mode: DEFAULT_MODE,
setMode: (mode) => set({ mode }),
selectedId: null,
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
selectedPlantingId: null,
selectPlanting: (id) => set({ selectedPlantingId: id, selectedId: null }),
focusedObjectId: null,
setFocusedObject: (id) => set({ focusedObjectId: id }),
railTab: null,
setRailTab: (tab) => set({ railTab: tab }),
seasonYear: null,
...TRANSIENT,
setSel: (sel) => set({ sel }),
setFocus: (id) => set({ focusId: id }),
setArmedKind: (kind) => set({ armedKind: kind, ghost: kind ? undefined : null } as Partial<EditorState>),
setArmedPlant: (plant, lotId = null) => set({ armedPlant: plant, armedLotId: plant ? lotId : null }),
setArmedLotId: (lotId) => set({ armedLotId: lotId }),
setGhost: (ghost) => set({ ghost }),
setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null,
// Scoping to a bed clears any plop scope, so only one is ever active.
setJournalObjectId: (id) => set({ journalObjectId: id, journalPlantingId: null }),
journalPlantingId: null,
setJournalPlantingId: (id) => set({ journalPlantingId: id, journalObjectId: null }),
armedPlant: null,
armedLotId: null,
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
liveObject: null,
setTab: (tab) => set({ tab }),
setMode: (mode) => set({ mode }),
setJournalScope: (scope) => set({ journalScope: scope }),
setLiveObject: (o) => set({ liveObject: o }),
livePlanting: null,
setLivePlanting: (p) => set({ livePlanting: p }),
armedKind: null,
setArmedKind: (kind) => set({ armedKind: kind }),
objectDragging: false,
setObjectDragging: (v) => set({ objectDragging: v }),
resetTransient: () =>
set({
selectedId: null,
selectedPlantingId: null,
focusedObjectId: null,
armedPlant: null,
armedLotId: null,
armedKind: null,
liveObject: null,
livePlanting: null,
railTab: null,
seasonYear: null,
journalObjectId: null,
journalPlantingId: null,
mode: DEFAULT_MODE,
}),
reset: () => set({ ...TRANSIENT }),
}))