# 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 here** — `OLLAMA_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 ``; 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 `` 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 `` (`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′` / `1′4″` 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 seam** — `ops.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 1–2 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.