Add DESIGN.md and README overview
Capture the v1 architecture: domain model, geometry, SVG editor with semantic zoom, REST API with version-guarded sync, service-layer seam for future majordomo/executus agent tools, OIDC-first auth (Authentik), and the phased roadmap. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# 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.
|
||||
- **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.
|
||||
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
||||
- **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.
|
||||
- **plants** — catalog. `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.
|
||||
- **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.
|
||||
- **One gesture library: `@use-gesture/react`** for 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
|
||||
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
||||
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
||||
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||
```
|
||||
|
||||
**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,
|
||||
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.)
|
||||
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/ later: llm.DefineTool[Args] wrappers over the SAME service methods
|
||||
web/ Vite app
|
||||
Makefile (cd web && npm run build) → copy dist → CGO_ENABLED=0 go build
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
- **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), `Inspector` (side panel; bottom sheet on mobile), `PlantPicker`. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, 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; optional mort-style API-key agent endpoint. The agent harness itself lives in majordomo/executus, outside pansy.
|
||||
|
||||
## 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; a scenario/year-plan layer is future work these dates don't block.
|
||||
4. Emoji plant icons (zero assets); SVG icon set later if wanted.
|
||||
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.
|
||||
@@ -1,2 +1,6 @@
|
||||
# pansy
|
||||
|
||||
Self-hostable garden planner: drag beds, bags, and containers onto a real-scale field, click into them to place freeform plops of plants, and zoom out to see what's planted where. Go backend + React frontend, one static binary.
|
||||
|
||||
See [DESIGN.md](DESIGN.md) for the architecture. Work is tracked in this repo's issues — start from the tracking epic.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user