Files
pansy/DESIGN.md
T
steveandClaude Fable 5 f985c264f8
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 4m18s
Adversarial Review (Gadfly) / review (pull_request) Successful in 4m18s
Agent: sharing tools that ask first, and a hard delete for a misplaced plop
list_shares, share_garden, remove_share and public_link (get / enable /
rotate / disable) wrap the sharing service. They change who can see a garden
beyond the screen, so they are gated twice: the prompt tells the model to say
exactly what it would do and ask, and the tools refuse without confirmed=true,
which their descriptions allow only after a yes in the conversation. The
refusal names the action, so the question the model asks is precise.

share_garden changes the role of an existing share instead of failing on it;
remove_share takes the email list_shares reports; an unknown email explains
that the person has to sign in once first. public_link returns the address
(PANSY_BASE_URL + /g/<token>, via the new Service.PublicShareURL), never a
bare token.

delete_planting is the hard delete for a plop that was never really planted,
as opposed to remove_planting's "it came out"; it is recorded, so undoable.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 02:12:51 -04:00

27 KiB
Raw Blame History

Pansy — Design

Pansy is a self-hostable garden planning web app. A landscaping-style 2D editor: drag garden objects (raised beds, grow bags, containers, in-ground plots, trees, paths) onto a field at real-world scale; click into a plantable object to place freeform "plops" of plants — a few plops of garlic in one corner, herbs in another, beans along a side — and zoom out to still see what's planted where. React frontend, Go backend, clean JSON API, single static binary. Works on desktop and is mobile-usable (touch drag, pinch zoom).

Work is tracked in Gitea issues; the tracking epic links every piece in dependency order.

Decisions

  • Placement model: freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
  • A fill is one of two operations (#77). A plop is a clump, not a plant, which is the right primitive for SKETCHING ("a few plops of garlic in a corner") but can't draw a real planting — a filled bed comes out as ~15 blobs, not 8 rows of garlic. So FillRegion/FillNamedRegion take a FillLayout: clump (default; plop radius 1.5×spacing, ~7 plants each — quick coverage) or grid (radius spacing/2, pitch = spacing, ONE plant per plop — a layout you could plant from). Surfaced on POST /objects/:id/fill (layout) and the agent's fill_region (mode). Same centered hexCenters lattice for both, but BOTH the plop radius (plopRadiusFor) and the edge inset (edgeInset) differ by layout: a grid plant sits at the plop's centre, so it insets a half-spacing; a clump's plants reach its rim, so it insets radius-less-a-half and overhangs the edge by that half — reusing the clump formula for grid would inset by zero and plant flush on the edge. A grid-filled bed approaches the low-hundreds-of-plops the SVG budget was sized for, which the semantic-zoom tiers already anticipate.
  • Spacing is a plant-to-plant rule, so bed edges get half of it. A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. FillRegion centres its lattice accordingly, and lets a plop — a clump three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and how it differs by layout are written out once in edgeInset (which hexCenters then honours); #75 is what getting it wrong looked like.
  • Stack: Go 1.26.x backend, module gitea.stevedudenhoeffer.com/steve/pansy; React + TypeScript + Vite + Tailwind frontend, production build embedded via embed.FS → one static binary (CGO_ENABLED=0).
  • Users: multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is is_admin (set race-free inside the INSERT); admin gates instance-wide Settings (requireAdmin), the only thing that reads that flag.
  • Auth: OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
  • Instance settings (#79): admin-editable, instance-wide config in a single-row instance_settings table — pansy's first DB-stored instance state (everything else hangs off a garden/object). Today it holds the agent model + on/off; secrets never move hereOLLAMA_CLOUD_API_KEY stays in the env so it doesn't land in backups or the undo history. Precedence: Settings value → env → default. The live agent Runner sits behind an atomic.Pointer in the API layer (agentHolder) with its routes always registered, so a settings change swaps it with no restart and no race against in-flight requests; /capabilities reads that pointer, so it reports what's live rather than what was configured at boot. The model/registry knowledge lives in one leaf package (internal/agentmodel) that both the runner and the settings validator import — agent imports service, so it can live in neither.
  • Seed-packet capture (#81): photograph a packet → a vision model (separate vision_model setting) reads it into structured fields via one-shot majordomo.Generate[SeedPacket] — NOT an agent loop, so the extraction can't touch the garden; it only reads a picture and returns data. The image is normalized to JPEG at the upload boundary (internal/imagenorm: decodes HEIC/webp/png/jpeg, since majordomo's stdlib media path can't do HEIC — the iPhone default; it also bakes in the JPEG EXIF orientation, since the re-encode strips EXIF and a phone photo tagged "rotate 90°" would otherwise reach the model sideways). The hard part is catalog matching, not OCR: a wrong auto-match splits a variety's seed-lot history across duplicate rows, so the service NEVER auto-creates — it surfaces ranked candidates (matchPlants) and the user confirms, then CreateFromPacket makes the plant (new or existing) + the lot. Plants/lots aren't in the undo history (they're catalog/inventory), so there's no change set to wrap. The extractor is injectable on the service (WithPacketExtractor) so the whole path tests hermetically against majordomo's fake provider.
  • Agentic future: integration with majordomo/executus via typed Go tools (llm.DefineTool[Args]) wrapping the same service layer the REST API uses — not MCP/OpenAPI.

Domain model

SQLite, centimeters everywhere (display-side imperial conversion only), version INTEGER on every user-mutable row for conflict detection.

  • users — email (unique), display_name, nullable argon2id password_hash, nullable oidc_issuer+oidc_subject (unique pair), is_admin. First registered user becomes admin. PANSY_REGISTRATION (open/closed) gates local signup. A user can be local-password, OIDC, or both (linked by email on first OIDC login).
  • sessions — sha256-hashed random 32-byte token, user_id, 30-day sliding expiry. HttpOnly cookie holds the raw token. Sessions are pansy-local regardless of auth method.
  • gardens — owner_id, name, width_cm, height_cm, unit_pref (metric|imperial), notes.
  • garden_shares — (garden_id, user_id) unique, role viewer|editor, created_by. Owner is implicit via gardens.owner_id, never a share row.
  • garden_objects — one polymorphic table: kind (bed|grow_bag|container|in_ground|tree|path|structure), name, shape (rect|circle; polygon + points JSON reserved in schema, not in the v1 editor), center x_cm,y_cm in garden space, width_cm,height_cm (circle: width=diameter), rotation_deg, z_index, plantable bool, nullable color override, props JSON for kind-specific extras (bed height, tree species…), notes.
  • seed_lots — one purchase: vendor, source URL, sku, lot code, purchase date, packed-for year, quantity + unit, cost, germination %. owner_id is on the LOT, not inherited from the plant, so you can record buying generic built-in Garlic from Johnny's and keep it private. plantings.seed_lot_id (ON DELETE SET NULL) attributes a plop to a purchase; remaining is derived (quantity summed effective count of active plantings), never stored — a decremented column drifts the moment a planting is edited behind its back and can't be audited afterwards. Lots are never shared along with a garden.
  • plants — catalog, plus source_url/vendor provenance for the variety itself. owner_id NULL = built-in (~30 seeded via migration, read-only, clone to customize). name, category (vegetable|herb|flower|fruit|tree_shrub|cover), spacing_cm (mature spacing), color hex, icon (emoji string — zero assets in v1), nullable days_to_maturity, notes. Users see built-ins + their own.
  • agent_conversations / agent_messages — the assistant's chat, one thread per (user, garden). Only the user/assistant TEXT of each turn is stored, not the model's full transcript: continuity needs what was said and what came back, and replaying a stored tool call would replay a decision made against a garden that has since moved on. agent_messages.change_set_id is the handle the chat panel uses to offer Undo on the turn that caused it.
  • journal_entries — the grow log: garden_id (NOT NULL), nullable object_id/planting_id to narrow the target, author, body, observed_at. notes on a garden/object/plant says what the thing IS and a new note overwrites the old; a journal entry says what HAPPENED and when, and entries accumulate. garden_id is set even for a bed- or plop-level entry so permission checks reuse requireGardenRole unchanged rather than inventing a second ACL path. observed_at is distinct from created_at — you write up Saturday's observations on Sunday. Deliberately not in the revision history: entries are already append-shaped and individually versioned.
  • change_sets / revisions — the undo substrate. A change set groups the row-level revisions one logical operation produced (source ui/agent/api, summary, nullable agent_run_id, nullable reverts_id); a revision is (entity_type, entity_id, op, before, after) with full JSON row snapshots. The unit of undo is the operation, not the row. Revert is itself a change set, so an undo can be undone; it is version-guarded per entity and reports conflicts rather than clobbering a later edit. Garden deletion is deliberately not revertible (the cascade is invisible to the service, and would take the history with it).
  • plantings ("plops") — object_id, plant_id, center x_cm,y_cm in the parent object's local frame (moving/rotating a bed moves its plants for free), radius_cm (a plop is a circular patch), nullable count (NULL = derived: max(1, round(π·r² / spacing_cm²))), nullable label, planted_at/removed_at dates. "Clear bed" sets removed_at; v1 shows rows where removed_at IS NULL. Year-over-year history and a future plan/scenario layer build on these dates without schema surgery.

Geometry

  • Garden space: origin top-left, x→right, y→down (screen-aligned), cm.
  • Objects: positioned by center point + rotation_deg clockwise about center → SVG translate(x y) rotate(deg) one-liners.
  • Object local frame: origin at object center, axes aligned to the unrotated object. Plops live in this frame.
  • Exactly two nesting levels: garden → object → plop. No recursive containers.
  • API and DB are metric-only; cm ↔ ft/in conversion is purely a frontend display concern.

Editor / rendering

  • Plain SVG in React. Tens of objects + low-hundreds of plops is far below SVG's ceiling; native DOM hit-testing, Tailwind styling, crisp text at any zoom. No Konva/canvas.
  • Hand-rolled pointer events, no gesture library. One pointer pans or drags, two pinch about their centroid, the wheel zooms to the cursor; a click is a drag that never crossed its threshold (3px mouse, 7px touch). Ported from the design prototype (web/src/editor/Canvas.tsx), which proved the model on both inputs.
  • Viewport = a single <g transform="translate(tx,ty) scale(s)">; state {tx, ty, scale} where scale = px per cm. Wheel/pinch zooms to cursor; drag on empty space pans; drag on an element moves it.
  • Field view and bed interior are the same canvas. "Click into a bed" animates the viewport to fit the object and sets focusedObjectId (mirrored to ?focus= URL param for deep links). Focused mode dims siblings and enables plop placement; Escape/tap-out zooms back.
  • Semantic zoom, by on-screen size rather than a fixed scale: a plop is always a circle in its plant's color; its monogram appears once its radius is ≥ 9px on screen, its plant name below it at ≥ 34px; an object's name appears when its longer side is > 54px. Zoomed right out the beds still read by their plops' colors — "what's planted where" at a glance.

API

REST under /api/v1, JSON, cookie-session auth (HttpOnly, SameSite=Lax, Secure under TLS). Gin with slog-gin + Recovery; embedded SPA fallback for non-/api routes.

POST /auth/register | /auth/login | /auth/logout     GET /auth/me      GET /auth/providers
GET  /auth/oidc/login      GET /auth/oidc/callback   (authorization-code + PKCE)
GET,POST /gardens                                    GET,PATCH,DELETE /gardens/:id
GET  /gardens/:id/full        ← one-shot editor load: garden + objects + plantings + referenced plants
GET  /gardens/:id/full?year=YYYY ← season view: plops whose time in the ground overlapped that year
GET  /gardens/:id/years       ← years this garden has planting data for
GET  /gardens/:id/history     ← change sets, newest first (paginated)
POST /change-sets/:id/revert  ← undo an operation; 201, or 409 + the conflicts it skipped
POST /gardens/:id/copy        ← deep-copy a garden you own (objects + active plops; not shares/link)
POST /gardens/:id/objects     PATCH,DELETE /objects/:id
POST /objects/:id/plantings   PATCH,DELETE /plantings/:id
POST /objects/:id/fill        ← hex-pack a region with one plant; region by compass name or rect; optional plantedAt (default UTC today)
POST /objects/:id/clear       ← soft-remove every active plop, as ONE change set
GET,POST /plants              PATCH,DELETE /plants/:id            (own plants only)
GET,POST /seed-lots           GET,PATCH,DELETE /seed-lots/:id     (own lots only; private)
POST /seed-lots/scan          ← multipart image → a seed-packet proposal (reads only, no writes)
POST /seed-lots/from-packet   ← confirmed proposal → a plant (new or existing) + a lot
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id           (editor writes; author edits own)
GET  /gardens/:id/journal/counts  ← entries per object, for the "has notes" indicator
POST /agent/chat              ← SSE: step events, then the finished turn (editor only);
                                 body {gardenId, message, today?} — today is the sender's LOCAL
                                 date, told to the model and stamped on everything the turn
                                 plants, removes or journals (server UTC day when omitted)
GET,DELETE /gardens/:id/agent/history                             (the actor's own thread)
GET  /capabilities            ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
GET,PATCH /settings           ← instance-wide config (admin only): agent model + on/off, vision model;
                                 plus a read-only `auth` view (registration mode, local auth, OIDC issuer)
GET,POST /gardens/:id/shares  PATCH,DELETE /gardens/:id/shares/:userId    (invite by email)
GET,POST,DELETE /gardens/:id/share-link   ← the public read-only token for this garden
GET  /public/gardens/:token   ← UNAUTHENTICATED read-only /full; the token is the capability

Sync: plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's version; the server increments on write and returns 409 + the current row on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.

Backend layout

Storage: modernc.org/sqlite (pure Go) + hand-written SQL via database/sql. WAL mode + busy_timeout=5000 (open pattern per executus contrib/store/sqlite.go). Migrations are numbered .sql files in an embed.FS with a tiny runner and a schema_migrations(version) table, run at startup; seed plants live in a migration.

cmd/pansy/main.go        config load → store.Open → migrate → service → api → ListenAndServe
internal/config/         PANSY_PORT, PANSY_DB, PANSY_BASE_URL, PANSY_REGISTRATION,
                         PANSY_OIDC_* (issuer/client_id/secret/button label), PANSY_LOCAL_AUTH,
                         PANSY_AGENT_MODEL / OLLAMA_CLOUD_API_KEY / PANSY_AGENT_ENABLED,
                         trusted proxies
internal/store/          sqlite.go (open/pragmas), migrate.go, migrations/*.sql (embedded),
                         queries per entity (users.go, gardens.go, objects.go, plantings.go,
                         plants.go, shares.go)
internal/domain/         plain structs + sentinel errors (ErrNotFound, ErrForbidden,
                         ErrVersionConflict)
internal/service/        ★ THE SEAM. All permission checks + business ops. auth.go, gardens.go,
                         objects.go, plantings.go, plants.go, shares.go, ops.go (FillRegion etc.),
                         revisions.go (change-set scoping, recording, revert)
internal/api/            thin gin handlers (decode → service → encode), session middleware,
                         ginserver setup, routes.go, oidc.go, spa.go (embed fallback)
internal/webdist/        dist.go: //go:embed all:dist   (web build copied in by Makefile)
internal/agent/          tools.go (llm.DefineTool wrappers over the SAME service methods),
                         runtime.go (model resolution, run loop, one change set per turn)
web/                     Vite app
Makefile                 (cd web && npm run build) → copy dist → CGO_ENABLED=0 go build

Every mutation is recorded. Service mutations call record(...), which joins the change set on the context if one is open and otherwise opens a single-op one for that mutation alone. That auto-scope is what keeps undo shallow: REST handlers need no changes at all, and only the agent wraps a whole turn (WithChangeSet) so a multi-step turn undoes as one unit. A multi-row operation passes all its changes in one record call for the same reason.

The service seam is the point. Every operation is a method on *service.Service taking (ctx, actorUserID, args); all ACL checks live there, not in handlers. REST handlers are thin adapters; future agent tools are equally thin llm.DefineTool[Args] adapters over the same methods — so agent calls inherit permission enforcement for free. Bulk ops designed for agents from the start, e.g. FillRegion(ctx, actor, objectID, region, plantID, spacingOverride) with region a rect/circle in object-local coordinates.

Auth

  • OIDC (primary; Authentik). github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 (pure Go). Authorization-code flow with PKCE + state cookie; issuer discovery from PANSY_OIDC_ISSUER; redirect URI derived from PANSY_BASE_URL. JIT provisioning on first login (the IdP gates access, so the registration flag doesn't apply); the email claim links to an existing local account if one matches, otherwise a user is created and stamped with oidc_issuer/oidc_subject.
  • Local (fallback). argon2id (x/crypto/argon2, id variant), subtle.ConstantTimeCompare. Disable entirely with PANSY_LOCAL_AUTH=false for pure-Authentik deployments.
  • Both paths end in the same pansy session cookie. GET /auth/providers tells the login page which methods to show.

Frontend layout

React 19 + TypeScript + Vite + Tailwind 4 (@tailwindcss/vite), @tanstack/react-router, @tanstack/react-query, zod for API parsing; dev proxy /api → Go server. The look is the "Organic" design handed off in docs/design_handoff_pansy_ui/ (Aug 2026): warm cream ground, terracotta + sage accents, Caprasimo display over Figtree body, every control a pill. That README is the visual spec; this section is how it's built.

  • Tokens, once. web/src/styles/index.css declares the handoff's styles.css variables (colors, fonts, radii, the --p-* canvas/ink tokens) through Tailwind's @theme, so utilities reference them by name — and dark mode is nothing but those same variables overridden on <html> by the bootstrap inlined in web/index.html (a port of the handoff's pansy-theme.js, run before first paint so a dark-mode user never sees a cream flash). No second stylesheet, no class swapping; web/src/lib/theme.ts is the typed React face of it.
  • 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.
  • 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 <garden> — <year> (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.
  • Pure helpers stay in web/src/lib/ (geometry, units including the compact 3 / 14″ display, monograms, plan names), unit-tested.

Roadmap

  1. Scaffold — Go module, config, gin server, healthz, sqlite + migration runner; Vite/Tailwind/router shell; embed.FS single-binary build.
  2. Auth — OIDC (Authentik) + local fallback, sessions, route guard.
  3. Gardens — CRUD + list page; establishes service-layer and version/409 conventions.
  4. Field editor — objects, canvas, pan/zoom/pinch, palette, move/resize/rotate, optimistic sync. Riskiest slice — test on a phone at the end of it, not later.
  5. Plant catalog — ~30 seeded built-ins + custom plants.
  6. Plops — focus-zoom, place/move/resize, derived counts, semantic zoom. The core vision lands here.
  7. Sharing — invite by email, roles, viewer read-only mode.
  8. Polish — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging.
  9. Agent seamops.go bulk ops + internal/agent DefineTool wrappers.
  10. Garden assistant — majordomo in-process, Ollama Cloud, streaming chat. Each turn runs inside ONE change set (source='agent'), so a turn that clears a bed and replants it undoes as one action; that is what makes acting without a confirmation prompt defensible. Bounded by a step cap and a timeout — loop safety, not spend control. The majordomo build tag is gone: a tag that keeps the agent out of the binary only earns its keep if you'd ship a build without it, and the agent is the point. What a day of live use added: the turn carries the gardener's local day (today in the chat body) into the prompt and every dated tool default, because the model's own idea of the date was a year stale and the server's is UTC; describe_garden groups plops by plant (count, where, planted date, days to maturity — DescribeGroup) and lists ids only for small groups, with list_plantings for the rest and remove_plantings to act on a whole group; move_planting relocates a plop (MovePlanting, within or across beds) keeping its planting date; fill_region takes an explicit local rectangle and a seedLotId; update_plant, read_history and copy_garden (the " — " plan convention) round out what the model kept reaching for. A mutation on another garden inside a turn is recorded under THAT garden (record refuses to file revisions into a scope for a different garden), so undo always finds them where the person is looking. The record-keeping round (2026-08-23): undo_change exposes RevertChangeSet with source=agent — the revert is its own change set, so an undo-only turn reports it as the turn's handle and "Undo this" becomes a redo; describe_garden takes a year (the season view, pulled plops included, removed/removedAt per group) with list_years beside it, for rotation questions; update_planting, update_journal_entry/delete_journal_entry and update_garden correct records in place, and remove_planting/remove_plantings/clear_object take a removedAt so a harvest can be backdated; the garden's notes go into the system prompt as the gardener's standing facts, and update_garden is how the assistant remembers what it is told. Round two added the catalog side — update_seed_lot/delete_seed_lot, delete_plant (refused while anything references the plant), create_garden — and readyAround on each describe group (planting date + days to maturity, pulled plops excluded), so "what can I pick this week?" is a lookup rather than arithmetic the model gets wrong. Round three is the outward-facing set — list_shares, share_garden, remove_share, public_link — gated twice: the prompt says to ask first, and the tools refuse without confirmed=true, which the description allows only after a yes in the conversation (a schema-level reminder, not a guarantee — the model could lie, but it has to do so explicitly); plus delete_planting for a plop that was never really planted (recorded, so undoable).

Deliberate v1 limits

  1. Plop count derived from area ÷ spacing², explicit override allowed.
  2. Shapes: rect + circle only (polygon reserved in schema).
  3. Seasons = planted/removed dates. ?year= filters /full to the plops whose [planted_at, removed_at] interval overlapped that calendar year, so garlic planted in October and pulled in July shows in both — and undated plops show in every year, since everything predating the feature has a null planted_at. Deliberately no seasons table: it would duplicate what the dates already say and create a second source of truth about when something was in the ground. Past seasons are read-only; #46's garden copy is the scenario-planning half.
  4. Plant markers are the plant's color plus a 12 letter monogram derived from its name (web/src/lib/monogram.ts) — still zero assets. The icon (emoji) column stays in the API for compatibility; the UI no longer shows it.
  5. No background/satellite image tracing (cheap to add later as a garden background field).
  6. 409-and-refetch conflict handling; no real-time sync.