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
+762
View File
@@ -0,0 +1,762 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type DragEvent,
type PointerEvent as ReactPointerEvent,
} from 'react'
import { clampScale, type Point } from '@/lib/geometry'
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import { formatSize } from '@/lib/units'
import { kindDef, objectStyle, rectRadius } from './kinds'
import {
DIM_OBJECT,
DIM_PLOP,
MAX_SCALE,
MIN_OBJECT_CM,
MIN_SCALE,
SNAP_CM,
clampPlopLocal,
defaultPlopRadius,
objectAt,
objectTransform,
rotatedHalfHeight,
snapPlopLocal,
toLocal,
toWorld,
} from './shared'
import { useEditorStore, type Viewport } from './store'
import type { EditorGarden, EditorObject } from './types'
const WHEEL_SENSITIVITY = 0.0016
const ANIM_MS = 520
const REFIT_THRESHOLD_PX = 60
const FT = 30.48
export interface CanvasHandle {
zoomFit: () => void
zoomIn: () => void
zoomOut: () => void
/** Frame a bed and enter focus mode (the page handles mode + URL). */
focusObject: (o: EditorObject) => void
}
type Drag =
| { t: 'pan'; sx: number; sy: number; tx: number; ty: number; moved: boolean; th: number }
| { t: 'obj'; base: EditorObject; sx: number; sy: number; moved: boolean; th: number }
| { t: 'plop'; base: EditorPlanting; obj: EditorObject; sx: number; sy: number; moved: boolean; th: number }
| { t: 'resize'; base: EditorObject; cx: number; cy: number; opp: Point; moved: boolean }
interface Pinch {
d0: number
cx0: number
cy0: number
s0: number
tx0: number
ty0: number
}
/**
* The garden canvas: one SVG, world = garden cm under a single translate/scale
* group. Everything the prototype does — wheel zoom to the cursor, two-finger
* pinch about the centroid, one-finger pan, drag-to-move with a 3″ snap, tap to
* select, semantic zoom on the plant markers, a ghost while a kind is armed,
* drag-and-drop from the toolkit — lives here, with native SVG hit-testing
* doing the picking. Drags commit ONE change on release (a PATCH with the row's
* version); placement creates. Corner handles on the selected object resize it.
*/
export const Canvas = forwardRef<
CanvasHandle,
{
garden: EditorGarden
objects: EditorObject[]
plantings: EditorPlanting[]
plantsById: Map<number, Plant>
letters: Map<number, string>
canEdit: boolean
isMobile: boolean
}
>(function Canvas({ garden, objects, plantings, plantsById, letters, canEdit, isMobile }, ref) {
const svgRef = useRef<SVGSVGElement>(null)
const hostRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState({ w: 0, h: 0 })
const fitSize = useRef({ w: 0, h: 0 })
const fittedGarden = useRef<number | null>(null)
const wasMobile = useRef(isMobile)
const animTimer = useRef<number | null>(null)
const pts = useRef(new Map<number, Point>())
const drag = useRef<Drag | null>(null)
const pinch = useRef<Pinch | null>(null)
const dropPlant = useRef<{ plant: Plant; lotId: number | null } | null>(null)
const vp = useEditorStore((s) => s.vp)
const anim = useEditorStore((s) => s.anim)
const sel = useEditorStore((s) => s.sel)
const focusId = useEditorStore((s) => s.focusId)
const armedKind = useEditorStore((s) => s.armedKind)
const armedPlant = useEditorStore((s) => s.armedPlant)
const ghost = useEditorStore((s) => s.ghost)
const liveObject = useEditorStore((s) => s.liveObject)
const livePlanting = useEditorStore((s) => s.livePlanting)
const createObject = useCreateObject(garden.id)
const createPlanting = useCreatePlanting(garden.id)
const updateObject = useUpdateObject(garden.id)
const updatePlanting = useUpdatePlanting(garden.id)
const GW = garden.widthCm
const GH = garden.heightCm
const s = vp.s
const snapStep = garden.snapToGrid && garden.gridSizeCm > 0 ? garden.gridSizeCm : SNAP_CM
// Objects in draw order, with any in-flight drag geometry merged in.
const rendered = useMemo(() => {
const sorted = [...objects].sort((a, b) => a.zIndex - b.zIndex || a.id - b.id)
return liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted
}, [objects, liveObject])
const renderedPlops = useMemo(
() => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings),
[plantings, livePlanting],
)
const byId = useMemo(() => new Map(rendered.map((o) => [o.id, o])), [rendered])
// Latest lists for the pointer handlers, which must not close over a render.
const latest = useRef({ rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep })
latest.current = { rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep }
// ── camera ──────────────────────────────────────────────────────────────
const camera = useCallback((next: Viewport, animate: boolean) => {
useEditorStore.getState().setVp(next, animate)
if (animTimer.current != null) window.clearTimeout(animTimer.current)
if (animate) {
animTimer.current = window.setTimeout(() => {
animTimer.current = null
useEditorStore.getState().setAnim(false)
}, ANIM_MS)
}
}, [])
const zoomFit = useCallback(() => {
const el = svgRef.current
if (!el) return
const w = el.clientWidth
const h = el.clientHeight
const pad = latest.current.isMobile ? 20 : 40
const { widthCm, heightCm } = latest.current.garden
const ns = clampScale(Math.min((w - pad * 2) / widthCm, (h - pad * 2) / heightCm), MIN_SCALE, MAX_SCALE)
if (!Number.isFinite(ns) || ns <= 0) return
fitSize.current = { w, h }
camera({ s: ns, tx: (w - widthCm * ns) / 2, ty: (h - heightCm * ns) / 2 }, true)
}, [camera])
const zoomBy = useCallback(
(f: number) => {
const el = svgRef.current
if (!el) return
const { tx, ty, s: cur } = useEditorStore.getState().vp
const ns = clampScale(cur * f, MIN_SCALE, MAX_SCALE)
const px = el.clientWidth / 2
const py = el.clientHeight / 2
camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, true)
},
[camera],
)
const focusObject = useCallback(
(o: EditorObject) => {
const el = svgRef.current
const st = useEditorStore.getState()
const m = latest.current.isMobile
st.setFocus(o.id)
st.setSel(m ? null : { type: 'object', id: o.id })
st.setArmedKind(null)
st.setGhost(null)
st.setTab('plot')
if (!el) return
const bw = o.rotationDeg % 180 ? o.heightCm : o.widthCm
const bh = o.rotationDeg % 180 ? o.widthCm : o.heightCm
const ns = Math.min(
MAX_SCALE * 0.75,
Math.min(el.clientWidth / (bw * (m ? 1.35 : 2.1)), el.clientHeight / (bh * (m ? 1.45 : 1.7))),
)
camera({ s: ns, tx: el.clientWidth / 2 - o.xCm * ns, ty: el.clientHeight / 2 - o.yCm * ns + (m ? 0 : 10) }, true)
},
[camera],
)
useImperativeHandle(ref, () => ({ zoomFit, zoomIn: () => zoomBy(1.45), zoomOut: () => zoomBy(1 / 1.45), focusObject }), [
zoomFit,
zoomBy,
focusObject,
])
// Measure; refit on a real size change (>60px) or when the chrome flips.
useEffect(() => {
const el = hostRef.current
if (!el) return
const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height }))
ro.observe(el)
return () => ro.disconnect()
}, [])
useEffect(() => {
if (size.w === 0 || size.h === 0 || GW === 0 || GH === 0) return
const first = fittedGarden.current !== garden.id
const flipped = wasMobile.current !== isMobile
const big =
Math.abs(size.w - fitSize.current.w) > REFIT_THRESHOLD_PX || Math.abs(size.h - fitSize.current.h) > REFIT_THRESHOLD_PX
wasMobile.current = isMobile
if (!first && !flipped && !big) return
fittedGarden.current = garden.id
// A focused bed (a ?focus= deep link, or a chrome flip mid-planting) frames
// its bed rather than the whole garden.
const f = useEditorStore.getState().focusId
const target = f != null ? latest.current.byId.get(f) : undefined
if (target) {
fitSize.current = { w: size.w, h: size.h }
focusObject(target)
} else zoomFit()
}, [size, garden.id, GW, GH, isMobile, zoomFit, focusObject])
// Wheel zooms to the cursor. Non-passive so the page doesn't scroll.
useEffect(() => {
const el = svgRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
if (!Number.isFinite(e.deltaY)) return
const r = el.getBoundingClientRect()
const { tx, ty, s: cur } = useEditorStore.getState().vp
const ns = clampScale(cur * Math.exp(-e.deltaY * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE)
const px = e.clientX - r.left
const py = e.clientY - r.top
camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, false)
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [camera])
useEffect(
() => () => {
if (animTimer.current != null) window.clearTimeout(animTimer.current)
},
[],
)
// ── pointer math ────────────────────────────────────────────────────────
const world = (e: { clientX: number; clientY: number }): Point => {
const r = svgRef.current!.getBoundingClientRect()
const { tx, ty, s: cur } = useEditorStore.getState().vp
return { x: (e.clientX - r.left - tx) / cur, y: (e.clientY - r.top - ty) / cur }
}
const snap = (v: number) => Math.round(v / latest.current.snapStep) * latest.current.snapStep
const thresh = (e: { pointerType?: string }) => (e.pointerType === 'touch' ? 7 : 3)
const track = (e: ReactPointerEvent) => {
const r = svgRef.current!.getBoundingClientRect()
pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
try {
svgRef.current!.setPointerCapture(e.pointerId)
} catch {
/* not all pointers capture */
}
}
const pinchStart = () => {
const [a, b] = [...pts.current.values()]
drag.current = null
const { s: cur, tx, ty } = useEditorStore.getState().vp
pinch.current = { d0: Math.hypot(a.x - b.x, a.y - b.y), cx0: (a.x + b.x) / 2, cy0: (a.y + b.y) / 2, s0: cur, tx0: tx, ty0: ty }
}
// ── placement ───────────────────────────────────────────────────────────
const placeObject = (kind: string, w: Point) => {
const def = kindDef(kind)
const st = useEditorStore.getState()
st.setArmedKind(null)
st.setGhost(null)
if (!def || !latest.current.canEdit) return
const n = latest.current.rendered.filter((o) => o.kind === def.kind).length + 1
createObject.mutate(
{
kind: def.kind,
shape: def.shape,
name: `${def.label} ${n}`,
xCm: snap(w.x),
yCm: snap(w.y),
widthCm: def.widthCm,
heightCm: def.heightCm,
zIndex: def.defaultZ,
plantable: def.plantable,
},
{ onSuccess: (o) => useEditorStore.getState().setSel({ type: 'object', id: o.id }) },
)
}
const placePlop = (o: EditorObject, local: Point, plant: Plant, lotId: number | null) => {
if (!latest.current.canEdit || !o.plantable) return
const r = defaultPlopRadius(plant)
const c = clampPlopLocal(o, snapPlopLocal(o, local), r)
createPlanting.mutate(
{ objectId: o.id, plantId: plant.id, xCm: c.x, yCm: c.y, radiusCm: r, seedLotId: lotId ?? undefined },
{
onSuccess: (p) => {
// Desktop selects what it just placed; the phone keeps the strip's plant
// armed and the peek closed so the next tap plants again.
if (!latest.current.isMobile) useEditorStore.getState().setSel({ type: 'plop', id: p.id })
},
},
)
}
// ── pointer handlers ────────────────────────────────────────────────────
const onCanvasDown = (e: ReactPointerEvent) => {
track(e)
if (pts.current.size === 2) return pinchStart()
const st = useEditorStore.getState()
const w = world(e)
if (st.armedKind) return placeObject(st.armedKind, w)
if (st.armedPlant) {
const o = st.focusId != null ? latest.current.byId.get(st.focusId) : objectAt(latest.current.rendered, w)
if (o?.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
return
}
if (!drag.current) drag.current = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.vp.tx, ty: st.vp.ty, moved: false, th: thresh(e) }
}
const objDown = (o: EditorObject) => (e: ReactPointerEvent) => {
e.stopPropagation()
track(e)
if (pts.current.size === 2) return pinchStart()
const st = useEditorStore.getState()
const w = world(e)
if (st.armedKind) return placeObject(st.armedKind, w)
if (st.armedPlant) {
if (o.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
return
}
// Dimmed siblings stay inert inside a focused bed.
if (st.focusId != null && o.id !== st.focusId) return
if (!latest.current.canEdit) {
st.setSel({ type: 'object', id: o.id })
st.setTab('plot')
return
}
drag.current = { t: 'obj', base: o, sx: w.x, sy: w.y, moved: false, th: thresh(e) }
}
const plopDown = (p: EditorPlanting) => (e: ReactPointerEvent) => {
const st = useEditorStore.getState()
if (st.armedKind || st.armedPlant) return // bubbles to the bed / canvas, which places
e.stopPropagation()
const o = latest.current.byId.get(p.objectId)
if (!o) return
// Outside focus a plop is just part of its bed: grabbing it grabs the bed.
if (st.focusId !== p.objectId) return objDown(o)(e)
track(e)
if (pts.current.size === 2) return pinchStart()
if (!latest.current.canEdit) {
st.setSel({ type: 'plop', id: p.id })
st.setTab('plot')
return
}
const l = toLocal(o, world(e))
drag.current = { t: 'plop', base: p, obj: o, sx: l.x, sy: l.y, moved: false, th: thresh(e) }
}
const handleDown = (o: EditorObject, cx: number, cy: number) => (e: ReactPointerEvent) => {
e.stopPropagation()
track(e)
if (!latest.current.canEdit) return
// The corner opposite the dragged one stays put, in the object's own frame.
drag.current = { t: 'resize', base: o, cx, cy, opp: { x: -cx * (o.widthCm / 2), y: -cy * (o.heightCm / 2) }, moved: false }
}
const onCanvasMove = (e: ReactPointerEvent) => {
const r = svgRef.current!.getBoundingClientRect()
if (pts.current.has(e.pointerId)) pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
const st = useEditorStore.getState()
if (pinch.current && pts.current.size >= 2) {
const [a, b] = [...pts.current.values()]
const p = pinch.current
const d1 = Math.hypot(a.x - b.x, a.y - b.y)
const cx = (a.x + b.x) / 2
const cy = (a.y + b.y) / 2
const ns = clampScale(p.s0 * (d1 / Math.max(1, p.d0)), MIN_SCALE, MAX_SCALE)
const wx = (p.cx0 - p.tx0) / p.s0
const wy = (p.cy0 - p.ty0) / p.s0
camera({ s: ns, tx: cx - wx * ns, ty: cy - wy * ns }, false)
return
}
const d = drag.current
if (!d) {
if (st.armedKind && e.pointerType !== 'touch') {
const w = world(e)
st.setGhost({ x: snap(w.x), y: snap(w.y) })
}
return
}
if (d.t === 'pan') {
if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > d.th) d.moved = true
camera({ ...st.vp, tx: d.tx + e.clientX - d.sx, ty: d.ty + e.clientY - d.sy }, false)
} else if (d.t === 'obj') {
const w = world(e)
if (Math.hypot(w.x - d.sx, w.y - d.sy) > d.th / st.vp.s) d.moved = true
if (!d.moved) return
st.setLiveObject({ ...d.base, xCm: snap(d.base.xCm + w.x - d.sx), yCm: snap(d.base.yCm + w.y - d.sy) })
} else if (d.t === 'plop') {
const l = toLocal(d.obj, world(e))
if (Math.hypot(l.x - d.sx, l.y - d.sy) > d.th / st.vp.s) d.moved = true
if (!d.moved) return
const next = clampPlopLocal(
d.obj,
snapPlopLocal(d.obj, { x: d.base.xCm + l.x - d.sx, y: d.base.yCm + l.y - d.sy }),
d.base.radiusCm,
)
st.setLivePlanting({ ...d.base, xCm: next.x, yCm: next.y })
} else if (d.t === 'resize') {
const p = toLocal(d.base, world(e))
d.moved = true
let newW = Math.max(MIN_OBJECT_CM, Math.abs(p.x - d.opp.x))
let newH = Math.max(MIN_OBJECT_CM, Math.abs(p.y - d.opp.y))
if (latest.current.garden.snapToGrid) {
const g = latest.current.snapStep
newW = Math.max(g, Math.round(newW / g) * g)
newH = Math.max(g, Math.round(newH / g) * g)
}
const dragged = { x: d.opp.x + d.cx * newW, y: d.opp.y + d.cy * newH }
const c = toWorld(d.base, { x: (d.opp.x + dragged.x) / 2, y: (d.opp.y + dragged.y) / 2 })
st.setLiveObject({ ...d.base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH })
}
}
const onCanvasUp = (e: ReactPointerEvent) => {
pts.current.delete(e.pointerId)
if (pinch.current) {
if (pts.current.size < 2) pinch.current = null
return
}
const d = drag.current
drag.current = null
if (!d) return
const st = useEditorStore.getState()
if (d.t === 'pan') {
if (!d.moved) st.setSel(null)
return
}
if (d.t === 'obj') {
if (!d.moved) {
st.setSel({ type: 'object', id: d.base.id })
st.setTab('plot')
return
}
const final = st.liveObject
st.setLiveObject(null)
if (final && (final.xCm !== d.base.xCm || final.yCm !== d.base.yCm))
updateObject.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
} else if (d.t === 'plop') {
if (!d.moved) {
st.setSel({ type: 'plop', id: d.base.id })
st.setTab('plot')
return
}
const final = st.livePlanting
st.setLivePlanting(null)
if (final) updatePlanting.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
} else if (d.t === 'resize') {
const final = st.liveObject
st.setLiveObject(null)
if (final && d.moved)
updateObject.mutate({
id: final.id,
version: final.version,
xCm: final.xCm,
yCm: final.yCm,
widthCm: final.widthCm,
heightCm: final.heightCm,
})
}
}
// HTML5 drag-and-drop from the toolkit (desktop).
const onDragOver = (e: DragEvent) => e.preventDefault()
const onDrop = (e: DragEvent) => {
e.preventDefault()
const data = e.dataTransfer.getData('text/plain')
if (!data) return
const w = world(e)
const [t, id] = data.split(':')
if (t === 'kind') placeObject(id, w)
if (t === 'plant' && dropPlant.current) {
const o = objectAt(latest.current.rendered, w)
if (o?.plantable) placePlop(o, toLocal(o, w), dropPlant.current.plant, dropPlant.current.lotId)
}
}
// The plant being dragged from the toolkit rides along in the store (the
// dataTransfer only carries its id); the armed plant is what's being dragged.
dropPlant.current = armedPlant ? { plant: armedPlant, lotId: useEditorStore.getState().armedLotId } : null
// ── derived drawing data ───────────────────────────────────────────────
const grid = useMemo(() => {
const imperial = garden.unitPref === 'imperial'
let step = imperial ? FT : 25
const majorEvery = imperial ? 5 : 4
while (Math.max(GW, GH) / step > 400) step *= majorEvery // a 100 m field still draws a sane number of lines
const minor: string[] = []
const major: string[] = []
for (let i = 1; i * step < GW; i++) (i % majorEvery ? minor : major).push(`M${(i * step).toFixed(2)} 0V${GH}`)
for (let i = 1; i * step < GH; i++) (i % majorEvery ? minor : major).push(`M0 ${(i * step).toFixed(2)}H${GW}`)
return { minor: minor.join(''), major: major.join('') }
}, [GW, GH, garden.unitPref])
const selectedObject = sel?.type === 'object' ? (byId.get(sel.id) ?? null) : null
const showLabels = s > 0.28
const handlePx = isMobile ? 14 : 9
const cursor = armedKind || armedPlant ? 'crosshair' : 'default'
return (
<div ref={hostRef} className="absolute inset-0">
<svg
ref={svgRef}
width="100%"
height="100%"
style={{ display: 'block', touchAction: 'none', cursor, userSelect: 'none' }}
onPointerDown={onCanvasDown}
onPointerMove={onCanvasMove}
onPointerUp={onCanvasUp}
onPointerCancel={onCanvasUp}
onDragOver={onDragOver}
onDrop={onDrop}
role="application"
aria-label={`${garden.name} — the plan. Tap a bed to select it; double-click to plant it.`}
>
<title>{garden.name} plan</title>
<g
className={anim ? 'camera-anim' : undefined}
style={{ transform: `translate(${vp.tx}px, ${vp.ty}px) scale(${s})`, transformOrigin: '0 0' }}
>
<rect x={0} y={0} width={GW} height={GH} rx={18} fill="var(--p-field)" stroke="var(--color-accent-2-500)" strokeWidth={3 / s} />
<path d={grid.minor} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.06} fill="none" />
<path d={grid.major} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.12} fill="none" />
{rendered.map((o) => {
const st = objectStyle(o)
const isSel = sel?.type === 'object' && sel.id === o.id
const dim = focusId != null && o.id !== focusId
const common = {
fill: st.fill,
stroke: isSel ? 'var(--color-accent)' : st.stroke,
strokeWidth: (isSel ? 3.5 : 2.5) / s,
strokeDasharray: st.dash,
}
return (
<g
key={o.id}
data-object-id={o.id}
transform={objectTransform(o)}
opacity={dim ? DIM_OBJECT : 1}
style={{ cursor: dim ? 'default' : canEdit ? 'grab' : 'pointer' }}
onPointerDown={objDown(o)}
onDoubleClick={o.plantable && !dim ? () => focusObject(o) : undefined}
>
{o.shape === 'circle' ? (
<ellipse rx={o.widthCm / 2} ry={o.heightCm / 2} {...common} />
) : (
<rect x={-o.widthCm / 2} y={-o.heightCm / 2} width={o.widthCm} height={o.heightCm} rx={rectRadius(o.widthCm)} {...common} />
)}
</g>
)
})}
{renderedPlops.map((p) => {
const o = byId.get(p.objectId)
if (!o) return null
const plant = plantsById.get(p.plantId)
const w = toWorld(o, { x: p.xCm, y: p.yCm })
const isSel = sel?.type === 'plop' && sel.id === p.id
const inFocus = focusId === p.objectId
return (
<g
key={p.id}
data-plop-id={p.id}
transform={`translate(${w.x} ${w.y})`}
opacity={focusId != null && !inFocus ? DIM_PLOP : 0.94}
style={{ cursor: inFocus ? (canEdit ? 'grab' : 'pointer') : 'inherit' }}
onPointerDown={plopDown(p)}
>
{/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
<circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
<circle r={p.radiusCm} fill={plant?.color ?? '#97a97c'} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
</g>
)
})}
{/* Labels — semantic zoom. pointer-events none so they never catch a tap. */}
<g style={{ pointerEvents: 'none' }}>
{showLabels &&
rendered.map((o) => {
if (!o.name || Math.max(o.widthCm, o.heightCm) * s <= 54 || (focusId != null && focusId !== o.id)) return null
const bh = rotatedHalfHeight(o)
return o.plantable ? (
<text
key={`ol${o.id}`}
x={o.xCm}
y={o.yCm - bh - 9 / s}
textAnchor="middle"
fontSize={13 / s}
fill="var(--p-ink-soft)"
style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.02em' }}
>
{o.name}
</text>
) : (
<text
key={`ol${o.id}`}
x={o.xCm}
y={o.yCm}
textAnchor="middle"
dominantBaseline="central"
fontSize={13 / s}
fill="var(--p-ink-mute)"
style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.06em' }}
>
{o.name}
</text>
)
})}
{renderedPlops.map((p) => {
const o = byId.get(p.objectId)
if (!o || (focusId != null && p.objectId !== focusId)) return null
const plant = plantsById.get(p.plantId)
const r = p.radiusCm
if (r * s < 9) return null
const w = toWorld(o, { x: p.xCm, y: p.yCm })
return (
<g key={`pl${p.id}`}>
<text
x={w.x}
y={w.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={r * 1.05}
fill="var(--color-paper)"
style={{ fontFamily: 'var(--font-heading)' }}
>
{letters.get(p.plantId) ?? '?'}
</text>
{r * s >= 34 && plant && (
<text
x={w.x}
y={w.y + r + 13 / s}
textAnchor="middle"
fontSize={11 / s}
fill="var(--p-ink-strong)"
style={{ fontFamily: 'var(--font-body)', fontWeight: 600 }}
>
{plant.name}
</text>
)}
</g>
)
})}
{selectedObject && (
<text
x={selectedObject.xCm}
y={selectedObject.yCm + rotatedHalfHeight(selectedObject) + 22 / s}
textAnchor="middle"
fontSize={12.5 / s}
fill="var(--color-accent-700)"
style={{ fontFamily: 'var(--font-body)', fontWeight: 700 }}
>
{formatSize(selectedObject.widthCm, selectedObject.heightCm, garden.unitPref)}
</text>
)}
</g>
{/* Selection: a dashed accent outline offset from the object, and —
for editors — corner handles to resize it. */}
{selectedObject && (
<g transform={objectTransform(selectedObject)}>
{selectedObject.shape === 'circle' ? (
<ellipse
rx={selectedObject.widthCm / 2 + 7 / s}
ry={selectedObject.heightCm / 2 + 7 / s}
fill="none"
stroke="var(--color-accent)"
strokeWidth={1.8 / s}
strokeDasharray={`${8 / s} ${6 / s}`}
style={{ pointerEvents: 'none' }}
/>
) : (
<rect
x={-selectedObject.widthCm / 2 - 7 / s}
y={-selectedObject.heightCm / 2 - 7 / s}
width={selectedObject.widthCm + 14 / s}
height={selectedObject.heightCm + 14 / s}
rx={16 / s}
fill="none"
stroke="var(--color-accent)"
strokeWidth={1.8 / s}
strokeDasharray={`${8 / s} ${6 / s}`}
style={{ pointerEvents: 'none' }}
/>
)}
{canEdit &&
!liveObject &&
(
[
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
] as const
).map(([cx, cy]) => (
<rect
key={`${cx},${cy}`}
x={cx * (selectedObject.widthCm / 2 + 7 / s) - handlePx / 2 / s}
y={cy * (selectedObject.heightCm / 2 + 7 / s) - handlePx / 2 / s}
width={handlePx / s}
height={handlePx / s}
rx={2 / s}
fill="var(--color-neutral-100)"
stroke="var(--color-accent)"
strokeWidth={1.5 / s}
style={{ cursor: cx * cy > 0 ? 'nwse-resize' : 'nesw-resize' }}
onPointerDown={handleDown(selectedObject, cx, cy)}
/>
))}
</g>
)}
{ghost && armedKind && (() => {
const def = kindDef(armedKind)
if (!def) return null
const st = objectStyle({ kind: def.kind })
return (
<g transform={`translate(${ghost.x} ${ghost.y})`} opacity={0.55} style={{ pointerEvents: 'none' }}>
{def.shape === 'circle' ? (
<circle r={def.widthCm / 2} fill={st.fill} stroke="var(--color-accent)" strokeWidth={2 / s} strokeDasharray={`${7 / s} ${5 / s}`} />
) : (
<rect
x={-def.widthCm / 2}
y={-def.heightCm / 2}
width={def.widthCm}
height={def.heightCm}
rx={12}
fill={st.fill}
stroke="var(--color-accent)"
strokeWidth={2 / s}
strokeDasharray={`${7 / s} ${5 / s}`}
/>
)}
</g>
)
})()}
</g>
</svg>
</div>
)
})