Gadfly findings on #95: - Correctness (3 models): the shared inset formula radius-spacing/2 collapses to 0 in grid mode (radius = spacing/2), so grid's outer row planted flush on / overhanging the bed edge instead of the half-spacing in that the rule wants. The inset genuinely differs by layout — a grid plant sits AT the plop centre (inset spacing/2), a clump's plants reach its rim (inset radius-spacing/2, overhanging by a half). Split it into a new edgeInset(radius, spacing, layout); hexCenters now takes a precomputed inset and is pure geometry (no spacing/layout knowledge). Regression guard: grid plants land at ±25 on a 60cm bed, not ±30. - Performance (2 findings): the in-loop `existing = append(existing, *p)` was dead — every plop in one fill shares a radius and sits on a distinct lattice point, and a plop is "covered" only when wholly inside another, impossible between equal-radius circles at different centres. Removing it stops the coveredByExisting scan growing during the fill (an empty-bed grid fill's check was needlessly quadratic in the plop count). - Docs: FillRegion/fillLoaded/hexCenters comments and the DESIGN.md bullets updated for plopRadiusFor/edgeInset (were still citing defaultPlopRadius and "written out in hexCenters"). - Test hygiene: split the grid + bad-layout cases out of TestFillAndClearAPI into TestFillLayoutAPI (one concern per test). The enum-tag finding is a non-issue: majordomo's DefineTool derives its arg schema from the same struct-tag reflection as Generate (proven by the vision SeedPacket enum), and the service validates mode regardless. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
20 KiB
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/FillNamedRegiontake aFillLayout:clump(default; plop radius 1.5×spacing, ~7 plants each — quick coverage) orgrid(radius spacing/2, pitch = spacing, ONE plant per plop — a layout you could plant from). SamehexCenterslattice and #75 edge rule for both; only the radius→spacing relationship differs. Surfaced onPOST /objects/:id/fill(layout) and the agent'sfill_region(mode). Same centeredhexCenterslattice 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.
FillRegioncentres 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 inedgeInset(whichhexCentersthen 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 viaembed.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_settingstable — 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_KEYstays 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 anatomic.Pointerin 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;/capabilitiesreads 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. - 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, nullableoidc_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 viagardens.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+pointsJSON reserved in schema, not in the v1 editor), centerx_cm,y_cmin garden space,width_cm,height_cm(circle: width=diameter),rotation_deg,z_index,plantablebool, nullablecoloroverride,propsJSON 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_idis 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;remainingis 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/vendorprovenance 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),colorhex,icon(emoji string — zero assets in v1), nullabledays_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_idis the handle the chat panel uses to offer Undo on the turn that caused it. - journal_entries — the grow log:
garden_id(NOT NULL), nullableobject_id/planting_idto narrow the target, author, body,observed_at.noteson 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_idis set even for a bed- or plop-level entry so permission checks reuserequireGardenRoleunchanged rather than inventing a second ACL path.observed_atis distinct fromcreated_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 (
sourceui/agent/api,summary, nullableagent_run_id, nullablereverts_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_cmin the parent object's local frame (moving/rotating a bed moves its plants for free),radius_cm(a plop is a circular patch), nullablecount(NULL = derived:max(1, round(π·r² / spacing_cm²))), nullable label,planted_at/removed_atdates. "Clear bed" setsremoved_at; v1 shows rows whereremoved_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_degclockwise about center → SVGtranslate(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.
- One gesture library:
@use-gesture/reactfor unified drag / wheel-zoom / pinch on desktop + touch. Everything else is hand-rolled pointer math. - 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, three bands by scale (thresholds tuned by feel):
- zoomed out (< ~0.75 px/cm): plops render as flat color patches; plantable objects show name + dominant-plant color — "what's planted where" at a glance;
- mid: plops as colored circles with the plant's emoji icon;
- zoomed in (> ~3 px/cm): icon + plant name + count per plop.
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
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)
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)
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
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 +statecookie; issuer discovery fromPANSY_OIDC_ISSUER; redirect URI derived fromPANSY_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 withoidc_issuer/oidc_subject. - Local (fallback). argon2id (
x/crypto/argon2, id variant),subtle.ConstantTimeCompare. Disable entirely withPANSY_LOCAL_AUTH=falsefor pure-Authentik deployments. - Both paths end in the same pansy session cookie.
GET /auth/providerstells 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.
- Routes:
/login,/register,/gardens(list),/gardens/:id(editor,?focus=objectId),/plants(catalog). Auth guard on the router root via/auth/me. - State: TanStack Query for all server state (editor keyed on
gardens/:id/full; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag. - Editor components (
web/src/editor/):GardenCanvas(svg root + viewport g),useViewport(use-gesture pan/zoom/pinch),ObjectShape,PlopMarker(semantic-zoom branching),Palette(drag-to-place object kinds),EditorRail(the one side panel),Inspector,HistoryPanel,PlantPicker. - One rail, tabs inside it. The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in
EditorRail— so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) inweb/src/lib/geometry.ts, unit-tested.
Roadmap
- Scaffold — Go module, config, gin server, healthz, sqlite + migration runner; Vite/Tailwind/router shell; embed.FS single-binary build.
- Auth — OIDC (Authentik) + local fallback, sessions, route guard.
- Gardens — CRUD + list page; establishes service-layer and version/409 conventions.
- 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.
- Plant catalog — ~30 seeded built-ins + custom plants.
- Plops — focus-zoom, place/move/resize, derived counts, semantic zoom. The core vision lands here.
- Sharing — invite by email, roles, viewer read-only mode.
- Polish — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging.
- Agent seam —
ops.gobulk ops +internal/agentDefineTool wrappers. - 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. Themajordomobuild 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.
Deliberate v1 limits
- Plop
countderived from area ÷ spacing², explicit override allowed. - Shapes: rect + circle only (polygon reserved in schema).
- Seasons = planted/removed dates.
?year=filters/fullto 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 nullplanted_at. Deliberately noseasonstable: 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. - Emoji plant icons (zero assets); SVG icon set later if wanted.
- No background/satellite image tracing (cheap to add later as a garden background field).
- 409-and-refetch conflict handling; no real-time sync.