Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c12dcfe2a |
@@ -42,13 +42,7 @@ jobs:
|
||||
# and cache the reusable-workflow ref, so a moved v1 tag keeps resolving to the
|
||||
# stale cached copy. A unique sha forces a cache miss → fresh fetch. Bump this
|
||||
# sha to adopt central swarm changes.
|
||||
#
|
||||
# 8adeeea resolves the reviewer image tag at RUN time (reviewer_tag input →
|
||||
# the owner's GADFLY_REVIEWER_TAG var → a baked fallback that exists in the
|
||||
# registry), so a retired image tag can't strand this stub again: the previous
|
||||
# pin (c9dab69) hard-coded gadfly:sha-b37cd09, which had been pruned from the
|
||||
# registry by 2026-08-22 and every review died at "manifest unknown".
|
||||
uses: steve/gadfly/.gitea/workflows/review-reusable.yml@8adeeeabe0738a797a1bdfc42c5176ea8ee627e4
|
||||
uses: steve/gadfly/.gitea/workflows/review-reusable.yml@c9dab69d143cb614c1840a5b06d6ffc358f4752d
|
||||
# Least privilege: forward only the review secrets (not `secrets: inherit`,
|
||||
# which would expose every repo secret). GITEA_TOKEN is the automatic token.
|
||||
secrets:
|
||||
|
||||
@@ -72,42 +72,6 @@ handler, put it in the service instead.
|
||||
Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
|
||||
`internal/webdist/dist` and embedded with `embed.FS`.
|
||||
|
||||
## The look comes from a handoff — don't improvise it
|
||||
|
||||
The frontend implements `docs/design_handoff_pansy_ui/` (read its README before
|
||||
touching anything visual; the `.dc.html` files there are references, not code).
|
||||
Conventions that follow from it:
|
||||
|
||||
- **Tokens live in two places, on purpose.** Light values in
|
||||
`web/src/styles/index.css` (`@theme`, same names as the handoff's
|
||||
`styles.css`); dark values ONLY in the bootstrap script in `web/index.html`.
|
||||
A new color goes in both; a raw hex in a component is wrong. Tailwind's shadow
|
||||
utilities inline their values and can't follow the runtime override — use
|
||||
`.elev-sm/md/lg` instead.
|
||||
- **The component classes are in index.css** (`.btn`, `.input`, `.tag`, `.seg`,
|
||||
`.toggle`, `.chip`, `.panel`, `.dialog`). Use them with Tailwind utilities for
|
||||
layout rather than restyling a pill from scratch.
|
||||
- **The editor's breakpoint is container width** (`PHONE_BREAKPOINT` = 760 in
|
||||
`web/src/editor/shared.ts`, measured with a ResizeObserver), not a media
|
||||
query. One component tree, two chromes; don't build a second page.
|
||||
- **Plant markers are monograms** derived from the name (`web/src/lib/monogram.ts`);
|
||||
the collision set is the whole catalog so the letters match everywhere.
|
||||
`plant.icon` still exists in the API but nothing renders it.
|
||||
- **Season plans are a naming convention** (`web/src/lib/plan.ts`): a copy named
|
||||
`<garden> — <year>` is that garden's plan. The API keeps no link; renaming the
|
||||
copy quietly makes it a plain garden, which is fine.
|
||||
- **Tap-to-place makes a one-plant plop** (radius = spacing/2, per the handoff);
|
||||
"Fill the bed" (rows / clumps) is the bulk tool. Fill geometry still follows
|
||||
the clump rules below — different tools, not a conflict.
|
||||
- **Undo in the header re-reads history before reverting.** 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. Keep it that way.
|
||||
- **Checking a change against the handoff:** run the API on a scratch DB
|
||||
(`PANSY_PORT=8099 PANSY_DB=/tmp/x.db GOWORK=off go run ./cmd/pansy`) plus
|
||||
`PANSY_PORT=8099 npx vite` in `web/`, seed through the API, and drive the
|
||||
Playwright MCP at 1280×800 and 390×844. Its screenshots must be named under
|
||||
`.playwright-mcp/` (gitignored) or it writes them into the repo root.
|
||||
|
||||
## Conventions that bite if you miss them
|
||||
|
||||
- **Everything is centimeters**, stored as SQLite `REAL`. Imperial is a display
|
||||
@@ -121,60 +85,13 @@ Conventions that follow from it:
|
||||
deliberately. `ErrForbidden` means "you can see it but may not do that".
|
||||
- **Plops (plantings) live in their parent object's local frame**, origin at the
|
||||
object's center, `-y` is north. Moving or rotating a bed moves its plants free.
|
||||
- **A plop is a clump, not a plant.** `defaultPlopRadius` is `1.5 × spacing`, so a
|
||||
plop is three spacings across and holds `π·r²/spacing²` plants. Reasoning about
|
||||
fills as if one plop were one plant gets the geometry wrong every time — which
|
||||
is how #75 happened: requiring the whole circle inside the bed inset the outer
|
||||
row by 1.5 spacings when the horticultural rule is *half* a spacing. Spacing is
|
||||
a constraint between neighbouring plants; a bed edge is nobody's neighbour.
|
||||
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads
|
||||
`removed_at IS NULL`. Hard delete is a different operation.
|
||||
- **Length fields keep centimeters as the source of truth.** A dialog field
|
||||
that takes a length is a `LengthField` (`web/src/lib/units.ts`): the text is
|
||||
a view, `cm` changes only when the person types. Never re-parse the display
|
||||
string on save — "29′ 6.3″" is the nearest tenth of an inch, and parsing it
|
||||
back is how a no-change Save turned 900 cm into 899.922 (and bumped the
|
||||
version, and wrote a bogus history entry). The inspector still keeps display
|
||||
strings but gets the same result by refusing to commit text that still equals
|
||||
the formatted original (`commitDim`); either way, a no-op save sends exactly
|
||||
what was loaded — or nothing.
|
||||
- **"Today" is the browser's local day**, from `today()` in
|
||||
`web/src/lib/dates.ts`, and the UI always sends it: journal `observedAt`,
|
||||
plop/fill `plantedAt`, `removedAt`. The server's UTC default is only for
|
||||
API callers and the agent. A gardener placing at 9 pm in Ohio planted today,
|
||||
not tomorrow — don't add a UI path that leaves the date to the server.
|
||||
- **A wrapped `ErrInvalidInput` is shown to the person verbatim.**
|
||||
`fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)`
|
||||
reaches the client as the 400's message (minus the sentinel prefix); the bare
|
||||
sentinel reads "invalid input". Write the reason for the keyboard, not the log.
|
||||
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
|
||||
at startup, embedded. Never edit one that has shipped.
|
||||
- **Every service mutation lands in history** (#48). If you add one, record it —
|
||||
see `internal/service/revisions.go`. Multi-row operations pass all their
|
||||
changes to a single `record` call so they undo as one unit.
|
||||
- **The history write is detached from cancellation on purpose.** `commitScope`
|
||||
calls `context.WithoutCancel` — that is not a mistake to tidy up. By the time
|
||||
a commit runs, the rows it describes are already written, so cancelling it
|
||||
cannot undo anything; it can only leave real changes with no way to undo them.
|
||||
This was a live bug twice (#73): a client disconnect mid-request orphaned 18
|
||||
plantings. Fixing it per-call-site is how it came back, which is why the rule
|
||||
lives in `commitScope` where no caller can forget it.
|
||||
|
||||
## Testing
|
||||
|
||||
Match the test to the failure it would catch:
|
||||
|
||||
- **Anything addressed by its own id needs an API-level test through the router.**
|
||||
Service tests can't see a route that was never registered — PATCH/DELETE
|
||||
`/journal/:id` once shipped fully implemented, fully unit-tested, and
|
||||
completely unreachable.
|
||||
- **Watch for fixtures that assert your assumptions instead of the API.** A test
|
||||
for the undo message passed because the fixture I wrote populated a field the
|
||||
real response leaves empty. If a test builds the thing it's testing against,
|
||||
it is checking your mental model, not the system.
|
||||
- Some things only real use finds. The agent's whole loop is covered by
|
||||
majordomo's scriptable fake provider (`provider/fake`), which is worth using —
|
||||
but the three worst v2 bugs all turned up in one live session afterwards.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -183,20 +100,6 @@ fix what's real → merge when the pipeline is green. Do not grade Gadfly findin
|
||||
A push to `main` builds the image and deploys to Komodo; the live instance at
|
||||
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
|
||||
|
||||
**One sweep, not a loop.** Take Gadfly's *initial* review, fix what's real, and
|
||||
merge once the build is green. Do NOT re-trigger Gadfly on the fix commits and
|
||||
wait for it again — a re-review-per-fix loop burns ~10 min a pass and drags a
|
||||
small PR out for an hour (Steve's explicit call, 2026-07-22). The initial review
|
||||
is the check; your own build/test/judgment covers the fixes. Gadfly is advisory
|
||||
and never blocks merge, so green build + fixes applied is enough.
|
||||
|
||||
(Mechanics, if you ever *do* need a manual re-run: the workflow triggers on
|
||||
`opened`/`reopened`/`ready_for_review`, not `synchronize`, so pushes don't
|
||||
re-review; a `@gadfly review` comment does. A comment without that exact phrase
|
||||
still runs and exits green in ~2s — a skip that looks like a pass, so judge a
|
||||
real review by its ~10-min duration, not its status. Gadfly edits its consensus
|
||||
comment in place, so `updated_at` moves but `created_at` doesn't.)
|
||||
|
||||
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
|
||||
without the PR dance.
|
||||
|
||||
@@ -215,25 +118,6 @@ Agent config: `OLLAMA_CLOUD_API_KEY`, `PANSY_AGENT_MODEL` (default
|
||||
strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives
|
||||
failover for free — don't parse that grammar in pansy.
|
||||
|
||||
The model and enabled flag are also **admin-editable at runtime** in Settings
|
||||
(#79); the env vars are just defaults (precedence: DB setting → env → default).
|
||||
Two things this makes load-bearing:
|
||||
|
||||
- **The live Runner is hot-swapped, not built once.** It sits behind an
|
||||
`atomic.Pointer` in `internal/api` (`agentHolder`), and its routes are
|
||||
registered *unconditionally* with a nil-check on `agent.get()`. Do NOT go back
|
||||
to registering the chat routes only when a key is present — a settings change
|
||||
has to be able to turn the assistant on without a restart, which a missing
|
||||
route can't. `/capabilities` reads the pointer, so it reflects the live state.
|
||||
- **`OLLAMA_CLOUD_API_KEY` stays in the environment, never the DB.** Model
|
||||
selection is a setting; the key is not. A secret in `instance_settings` lands
|
||||
in every backup and in the undo history's blast radius. The Settings API
|
||||
reports whether a key is *present*, never its value.
|
||||
- The "how to turn a spec into a model" knowledge lives once in
|
||||
`internal/agentmodel`, imported by both `agent` (to run) and `service` (to
|
||||
validate a spec before storing it). It can't live in `agent` — `agent` imports
|
||||
`service`, so a `service`→`agent` import would cycle.
|
||||
|
||||
`majordomo` is a **real dependency** now, resolved from the Gitea instance as a
|
||||
pseudo-version. There is no `replace` directive and there must not be one: a
|
||||
`replace` pointing at `../majordomo` builds on your laptop and breaks the Docker
|
||||
|
||||
@@ -7,13 +7,9 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
|
||||
## 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.
|
||||
- **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.
|
||||
- **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
|
||||
@@ -43,10 +39,13 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version
|
||||
## 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.
|
||||
- **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**, 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.
|
||||
- **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
|
||||
|
||||
@@ -64,22 +63,14 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
|
||||
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)
|
||||
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 /capabilities ← what this instance can do, so the UI offers only what works
|
||||
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.
|
||||
@@ -123,17 +114,12 @@ Makefile (cd web && npm run build) → copy dist → CGO_ENABLED
|
||||
|
||||
## 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.
|
||||
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
|
||||
|
||||
- **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′` / `1′4″` display, monograms, plan names), unit-tested.
|
||||
- **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) in `web/src/lib/geometry.ts`, unit-tested.
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -153,6 +139,6 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -67,31 +67,16 @@ The garden assistant reads three more. Setting none of them leaves the assistant
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ------------------------------ | --------------------------------------------------------------------------- |
|
||||
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is off, not broken. This is the one agent value that stays in the environment — it is **never** stored in the database or editable in Settings. |
|
||||
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Default model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. An admin can override this per-instance in **Settings** without a redeploy; a blank Settings value inherits this. |
|
||||
| `PANSY_AGENT_ENABLED` | on when a key is present | Default on/off for the assistant. Also overridable in Settings (which can inherit this default). |
|
||||
| `PANSY_VISION_MODEL` | *(empty)* | Default model for **seed-packet capture** (photograph a packet → it fills in the plant + purchase). A *vision-capable* model (the chat model may not be). Empty = the feature isn't offered. Runs against the same `OLLAMA_CLOUD_API_KEY`, and is overridable in Settings. |
|
||||
|
||||
The agent model + enabled flag, and the vision model, can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — an agent change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence is **Settings value, if set → env var → built-in default**.
|
||||
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is disabled, not broken — the chat routes simply aren't registered. |
|
||||
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. |
|
||||
| `PANSY_AGENT_ENABLED` | on when a key is present | Turns the assistant off without removing the key. |
|
||||
|
||||
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
||||
|
||||
**If you set the key and the assistant still doesn't appear**, check that the variable reaches the *container*, not just your orchestrator's stack config — Compose needs it listed under the service's `environment:`. pansy logs why the assistant is off at startup, and Settings shows the same status (a key present, the resolved model, and whether it's actually running).
|
||||
|
||||
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
|
||||
|
||||
OIDC (Authentik-first) is live too: set `PANSY_OIDC_ISSUER`, `PANSY_OIDC_CLIENT_ID`, `PANSY_OIDC_CLIENT_SECRET`, and `PANSY_BASE_URL` (needed for the redirect URI). Register `PANSY_BASE_URL` + `/api/v1/auth/oidc/callback` as the redirect URI in your IdP. `GET /auth/oidc/login` starts an authorization-code + PKCE flow; first login provisions a user just-in-time (a matching *verified* email links to an existing local account instead of duplicating it). Provider discovery is lazy, so a briefly-unreachable IdP never blocks startup or local auth. Set `PANSY_LOCAL_AUTH=false` for pure-Authentik deployments (local register/login are then rejected and hidden from `/auth/providers`).
|
||||
|
||||
## The UI
|
||||
|
||||
The frontend implements the design handoff in [`docs/design_handoff_pansy_ui/`](docs/design_handoff_pansy_ui/README.md) — read that README before changing how anything looks; the `.dc.html` files there are references, not shipped code.
|
||||
|
||||
- **Theme.** Light by default, with a `system | light | dark` preference (the monitor/sun/moon button in every nav, or Settings → Appearance) kept in `localStorage['pansy-theme']`. Dark mode is the same stylesheet with its tokens overridden on `<html>` by a small script in `web/index.html`, which runs before the first paint.
|
||||
- **Fonts.** Caprasimo and Figtree are loaded from Google Fonts (`fonts.googleapis.com`). That is the app's only request to a third party; offline it falls back to system fonts and everything else works.
|
||||
- **Phone vs desktop.** The editor picks its chrome by *container* width — below 760px it is the one-column phone layout (mode bar, tool strip, a peek panel that docks between the canvas and the bar); above it, the three-card workspace. Every other page is one responsive layout.
|
||||
- **Season plans.** "Copy — plan a season from it" duplicates a garden (`POST /gardens/:id/copy`) under the name `<garden> — <year>`; the editor's season control and the `plan` tag on the gardens list read that name back. Rename the copy and it is just a garden again.
|
||||
- **Settings → Who gets in** is read-only: `PANSY_REGISTRATION`, `PANSY_LOCAL_AUTH` and the OIDC issuer are reported there (the `auth` block of `GET /settings`) so an admin can see what is in force without shell access; they deploy with the environment.
|
||||
|
||||
## Docker & deployment
|
||||
|
||||
CI (`.gitea/workflows/build-image.yml`) builds the single-binary image and pushes it to the Gitea registry on every branch push:
|
||||
@@ -126,8 +111,7 @@ services:
|
||||
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
|
||||
# PANSY_OIDC_CLIENT_ID: ...
|
||||
# PANSY_OIDC_CLIENT_SECRET: ...
|
||||
# OLLAMA_CLOUD_API_KEY: ${OLLAMA_CLOUD_API_KEY} # enables the garden assistant
|
||||
# PANSY_AGENT_MODEL: ollama-cloud/glm-5.2:cloud
|
||||
# OLLAMA_CLOUD_API_KEY: ... # enables the garden assistant
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
pansy-data:
|
||||
|
||||
@@ -1,901 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||
<script src="pansy-theme.js"></script>
|
||||
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||
<style>
|
||||
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||
html,body{height:100%;margin:0}
|
||||
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||
::-webkit-scrollbar{height:6px;width:6px} ::-webkit-scrollbar-thumb{background:var(--color-neutral-400);border-radius:99px} ::-webkit-scrollbar-track{background:transparent}
|
||||
</style>
|
||||
</helmet>
|
||||
<div ref="{{ rootRef }}" style="height:100%;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text);user-select:none">
|
||||
|
||||
<sc-if value="{{ isDesktop }}" hint-placeholder-val="{{ true }}">
|
||||
<div data-screen-label="Editor — desktop" style="height:100%;display:flex;flex-direction:column">
|
||||
<nav class="nav" style="flex:none">
|
||||
<span class="nav-brand" style="display:flex;align-items:center;gap:8px">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||
pansy
|
||||
</span>
|
||||
<a href="Pansy Gardens.dc.html" aria-current="page">Gardens</a>
|
||||
<a href="Pansy Plants.dc.html">Plants</a>
|
||||
<span style="margin-left:auto;display:flex;align-items:center;gap:10px">
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||
</button>
|
||||
<a href="Pansy Settings.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px" title="Settings">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z"></path></svg>
|
||||
</a>
|
||||
<a href="Pansy Login.dc.html" style="width:32px;height:32px;border-radius:999px;background:var(--color-accent-2-300);color:var(--color-accent-2-800);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;text-decoration:none">S</a>
|
||||
</span>
|
||||
</nav>
|
||||
<div style="flex:1;display:grid;grid-template-columns:216px minmax(0,1fr) 336px;gap:14px;padding:14px;min-height:0">
|
||||
|
||||
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:14px;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:8px">
|
||||
<sc-if value="{{ notFocus }}" hint-placeholder-val="{{ true }}">
|
||||
<h6 style="margin:4px 4px 6px">Toolkit</h6>
|
||||
<sc-for list="{{ kindsR }}" as="k" hint-placeholder-count="7">
|
||||
<button draggable="true" onDragStart="{{ k.onDrag }}" onClick="{{ k.onClick }}" style="{{ k.btnStyle }}" title="Drag onto the plan — or click to arm, then click the plan">
|
||||
<svg width="38" height="30" viewBox="-20 -15 40 30" style="flex:none">
|
||||
<sc-if value="{{ k.isRect }}"><rect x="{{ k.mx0 }}" y="{{ k.my0 }}" width="{{ k.mw }}" height="{{ k.mh }}" rx="3" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></rect></sc-if>
|
||||
<sc-if value="{{ k.isCircle }}"><circle r="{{ k.mr }}" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></circle></sc-if>
|
||||
</svg>
|
||||
<span style="display:flex;flex-direction:column;align-items:flex-start;gap:1px">
|
||||
<span style="font-size:13px;font-weight:700;white-space:nowrap">{{ k.label }}</span>
|
||||
<span style="font-size:11px;color:var(--p-ink-mute);white-space:nowrap">{{ k.sizeText }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
<div style="margin-top:auto;font-size:12px;color:var(--p-ink-mute);line-height:1.5;padding:8px 6px 2px">Drag a shape onto the plan. Double-click any bed to plant it.</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ focusName }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin:2px 0 4px">
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:30px;height:30px;flex:none" onClick="{{ onBack }}" title="Back to the plan">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>
|
||||
</button>
|
||||
<span style="font-family:var(--font-heading);font-size:16px;line-height:1.15">{{ focusName }}</span>
|
||||
</div>
|
||||
<input class="input" style="border-radius:999px" placeholder="Find a plant…" value="{{ q }}" onChange="{{ onQ }}">
|
||||
<sc-for list="{{ plantsR }}" as="c" hint-placeholder-count="8">
|
||||
<button draggable="true" onDragStart="{{ c.onDrag }}" onClick="{{ c.onClick }}" style="{{ c.btnStyle }}" title="Drag into the bed — or click to arm, then click the bed">
|
||||
<svg width="16" height="16" style="flex:none"><circle cx="8" cy="8" r="8" fill="{{ c.color }}"></circle></svg>
|
||||
<span style="font-size:13px;font-weight:700">{{ c.name }}</span>
|
||||
<span style="font-size:11px;color:var(--p-ink-mute);margin-left:auto">{{ c.spacingText }}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);display:flex;flex-direction:column;min-height:0;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--color-divider);flex-wrap:wrap">
|
||||
<h4 style="margin:0;font-size:19px">Home Garden</h4>
|
||||
<span style="font-size:13px;color:var(--p-ink-mute);font-weight:600">40′ × 28′</span>
|
||||
<sc-if value="{{ focusName }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="opacity:0.4">/</span><span style="font-size:14px;font-weight:700;color:var(--color-accent-700)">{{ focusName }}</span>
|
||||
</sc-if>
|
||||
<span style="margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;min-width:0">
|
||||
<span style="display:flex;background:var(--color-neutral-200);border-radius:999px;padding:3px">
|
||||
<sc-for list="{{ seasonsR }}" as="sn">
|
||||
<button onClick="{{ sn.onClick }}" style="{{ sn.style }}">{{ sn.label }}</button>
|
||||
</sc-for>
|
||||
</span>
|
||||
<button class="btn btn-secondary" style="border-radius:999px;gap:7px" onClick="{{ onUndo }}" disabled="{{ undoDisabled }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg>
|
||||
Undo
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<sc-if value="{{ banner }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="padding:8px 18px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:13px;font-weight:600">{{ banner }}</div>
|
||||
</sc-if>
|
||||
<div style="flex:1;min-height:0;position:relative">
|
||||
<svg ref="{{ svgRef }}" width="100%" height="100%" style="{{ svgStyle }}" onPointerDown="{{ onCanvasDown }}" onPointerMove="{{ onCanvasMove }}" onPointerUp="{{ onCanvasUp }}" onPointerCancel="{{ onCanvasUp }}" onDragOver="{{ onDragOver }}" onDrop="{{ onDrop }}">
|
||||
<g style="{{ viewStyle }}">
|
||||
<rect x="0" y="0" width="{{ gw }}" height="{{ gh }}" rx="18" fill="var(--p-field)" stroke="var(--color-accent-2-500)" stroke-width="{{ borderW }}"></rect>
|
||||
<path d="{{ gridMinor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.06" fill="none"></path>
|
||||
<path d="{{ gridMajor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.12" fill="none"></path>
|
||||
<sc-for list="{{ objectsR }}" as="o" hint-placeholder-count="4">
|
||||
<g transform="{{ o.transform }}" opacity="{{ o.opacity }}" style="cursor:grab" onPointerDown="{{ o.onDown }}" onDoubleClick="{{ o.onDbl }}">
|
||||
<sc-if value="{{ o.isRect }}" hint-placeholder-val="{{ true }}"><rect x="{{ o.x0 }}" y="{{ o.y0 }}" width="{{ o.w }}" height="{{ o.h }}" rx="{{ o.rr }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></rect></sc-if>
|
||||
<sc-if value="{{ o.isCircle }}" hint-placeholder-val="{{ false }}"><circle r="{{ o.r }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></circle></sc-if>
|
||||
</g>
|
||||
</sc-for>
|
||||
<sc-for list="{{ plopsR }}" as="p" hint-placeholder-count="0">
|
||||
<g transform="{{ p.transform }}" opacity="{{ p.opacity }}" style="{{ p.gStyle }}" onPointerDown="{{ p.onDown }}">
|
||||
<circle r="{{ p.r }}" fill="{{ p.fill }}" stroke="{{ p.stroke }}" stroke-width="{{ p.sw }}"></circle>
|
||||
</g>
|
||||
</sc-for>
|
||||
<g style="pointer-events:none">{{ labelsLayer }}</g>
|
||||
<sc-if value="{{ selO }}" hint-placeholder-val="{{ false }}">
|
||||
<g transform="{{ selO.transform }}" style="pointer-events:none">
|
||||
<sc-if value="{{ selO.isRect }}"><rect x="{{ selO.x0 }}" y="{{ selO.y0 }}" width="{{ selO.w }}" height="{{ selO.h }}" rx="{{ selO.rr }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></rect></sc-if>
|
||||
<sc-if value="{{ selO.isCircle }}"><circle r="{{ selO.r }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></circle></sc-if>
|
||||
</g>
|
||||
</sc-if>
|
||||
<sc-if value="{{ ghostR }}" hint-placeholder-val="{{ false }}">
|
||||
<g transform="{{ ghostR.transform }}" opacity="0.55" style="pointer-events:none">
|
||||
<sc-if value="{{ ghostR.isRect }}"><rect x="{{ ghostR.x0 }}" y="{{ ghostR.y0 }}" width="{{ ghostR.w }}" height="{{ ghostR.h }}" rx="12" fill="{{ ghostR.fill }}" stroke="var(--color-accent)" stroke-width="{{ ghostR.sw }}" stroke-dasharray="{{ ghostR.dash }}"></rect></sc-if>
|
||||
<sc-if value="{{ ghostR.isCircle }}"><circle r="{{ ghostR.r }}" fill="{{ ghostR.fill }}" stroke="var(--color-accent)" stroke-width="{{ ghostR.sw }}" stroke-dasharray="{{ ghostR.dash }}"></circle></sc-if>
|
||||
</g>
|
||||
</sc-if>
|
||||
</g>
|
||||
</svg>
|
||||
<div style="position:absolute;bottom:12px;right:12px;display:flex;gap:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:999px;box-shadow:var(--shadow-sm);padding:4px">
|
||||
<button class="btn btn-icon" style="border-radius:999px;width:30px;height:30px" style-hover="background:var(--color-accent-100)" onClick="{{ zoomOut }}" title="Zoom out"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path></svg></button>
|
||||
<button class="btn btn-icon" style="border-radius:999px;width:30px;height:30px" style-hover="background:var(--color-accent-100)" onClick="{{ zoomFit }}" title="Fit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path></svg></button>
|
||||
<button class="btn btn-icon" style="border-radius:999px;width:30px;height:30px" style-hover="background:var(--color-accent-100)" onClick="{{ zoomIn }}" title="Zoom in"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);display:flex;flex-direction:column;min-height:0;overflow:hidden">
|
||||
<div style="display:flex;gap:4px;padding:10px 10px 0">
|
||||
<sc-for list="{{ tabsR }}" as="t">
|
||||
<button onClick="{{ t.onClick }}" style="{{ t.style }}">{{ t.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px">
|
||||
<sc-if value="{{ tabPlot }}" hint-placeholder-val="{{ true }}">
|
||||
<sc-if value="{{ insp }}" hint-placeholder-val="{{ false }}">
|
||||
<sc-if value="{{ insp.isObj }}">
|
||||
<input class="input" style="border-radius:999px;font-weight:700" value="{{ insp.name }}" onChange="{{ insp.onRename }}">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span class="tag tag-neutral" style="border-radius:999px">{{ insp.kindLabel }}</span>
|
||||
<span style="font-size:13px;color:var(--p-ink-soft);font-weight:600">{{ insp.sizeText }}</span>
|
||||
</div>
|
||||
<sc-if value="{{ insp.rosterText }}"><div style="font-size:12.5px;color:var(--p-ink-soft);line-height:1.5">{{ insp.rosterText }}</div></sc-if>
|
||||
<div style="display:flex;gap:8px">
|
||||
<sc-if value="{{ insp.plantable }}"><button class="btn btn-primary" style="border-radius:999px;flex:1" onClick="{{ insp.onOpen }}">Plant this</button></sc-if>
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ insp.onRotate }}" title="Rotate 90°"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg></button>
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ insp.onDelete }}" title="Remove"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-700)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ insp.isPlop }}">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<svg width="18" height="18"><circle cx="9" cy="9" r="8" fill="{{ insp.plantColor }}"></circle></svg>
|
||||
<span style="font-family:var(--font-heading);font-size:17px">{{ insp.plantName }}</span>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--p-ink-soft)">{{ insp.countText }} · {{ insp.plopSize }} patch</div>
|
||||
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ insp.onDelete }}">Pull it out</button>
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
<sc-if value="{{ summary }}" hint-placeholder-val="{{ true }}">
|
||||
<h5 style="margin:2px 0 0">This garden</h5>
|
||||
<div style="font-size:13px;color:var(--p-ink-soft);line-height:1.6">{{ summary.countsText }}</div>
|
||||
<div style="display:flex;flex-direction:column;gap:7px">
|
||||
<sc-for list="{{ summary.roster }}" as="r" hint-placeholder-count="3">
|
||||
<div style="display:flex;align-items:center;gap:9px">
|
||||
<svg width="13" height="13" style="flex:none"><circle cx="6.5" cy="6.5" r="6.5" fill="{{ r.color }}"></circle></svg>
|
||||
<span style="font-size:13px;font-weight:600">{{ r.name }}</span>
|
||||
<span style="font-size:12px;color:var(--p-ink-mute);margin-left:auto">{{ r.where }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--p-ink-mute);line-height:1.5;margin-top:auto;padding-top:10px">Select anything on the plan to edit it here. Double-click a bed to plant it.</div>
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
<sc-if value="{{ tabJournal }}" hint-placeholder-val="{{ false }}">
|
||||
<sc-for list="{{ entriesR }}" as="e" hint-placeholder-count="3">
|
||||
<div style="background:var(--color-bg);border:1px solid var(--color-divider);border-radius:var(--radius-md);padding:12px 14px">
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-bottom:5px">
|
||||
<span style="font-size:11.5px;font-weight:700;color:var(--color-accent-2-700)">{{ e.obj }}</span>
|
||||
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">{{ e.d }}</span>
|
||||
</div>
|
||||
<div style="font-size:13px;line-height:1.5">{{ e.txt }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
<div style="display:flex;gap:6px;margin-top:auto;padding-top:6px">
|
||||
<input class="input" style="border-radius:999px" placeholder="{{ jrPlaceholder }}" value="{{ jr }}" onChange="{{ onJr }}" onKeyDown="{{ onJrKey }}">
|
||||
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none" onClick="{{ onAddJr }}" title="Log it">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ tabHistory }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="font-size:12.5px;color:var(--p-ink-mute);line-height:1.5">Every change — yours or the assistant's — is one undoable step.</div>
|
||||
<sc-for list="{{ historyR }}" as="h" hint-placeholder-count="2">
|
||||
<div style="display:flex;align-items:center;gap:9px;background:var(--color-bg);border:1px solid var(--color-divider);border-radius:999px;padding:8px 14px">
|
||||
<span style="width:7px;height:7px;border-radius:99px;background:var(--color-accent-400);flex:none"></span>
|
||||
<span style="font-size:13px;font-weight:600">{{ h.label }}</span>
|
||||
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">{{ h.when }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
<sc-if value="{{ historyEmpty }}"><div style="font-size:13px;color:var(--p-ink-mute)">Nothing yet this session — go move something.</div></sc-if>
|
||||
<button class="btn btn-secondary" style="border-radius:999px;margin-top:auto" onClick="{{ onUndo }}" disabled="{{ undoDisabled }}">Undo the last step</button>
|
||||
</sc-if>
|
||||
<sc-if value="{{ tabChat }}" hint-placeholder-val="{{ false }}">
|
||||
<sc-for list="{{ msgsR }}" as="m" hint-placeholder-count="2">
|
||||
<div style="{{ m.style }}">{{ m.txt }}</div>
|
||||
</sc-for>
|
||||
<div style="display:flex;gap:6px;margin-top:auto;padding-top:6px">
|
||||
<input class="input" style="border-radius:999px" placeholder="Ask about your garden…" value="{{ chat }}" onChange="{{ onChat }}" onKeyDown="{{ onChatKey }}">
|
||||
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none" onClick="{{ onSend }}" title="Send">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"></path><path d="M22 2 11 13"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ isMobile }}" hint-placeholder-val="{{ false }}">
|
||||
<div data-screen-label="Editor — phone" style="height:100%;display:flex;flex-direction:column">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px;flex:none">
|
||||
<sc-if value="{{ focusName }}" hint-placeholder-val="{{ false }}">
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px;flex:none;width:38px;height:38px" onClick="{{ onBack }}" title="Back to the plan">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>
|
||||
</button>
|
||||
</sc-if>
|
||||
<sc-if value="{{ notFocus }}" hint-placeholder-val="{{ true }}">
|
||||
<a href="Pansy Gardens.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px;flex:none;width:38px;height:38px;text-decoration:none" title="Your gardens">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>
|
||||
</a>
|
||||
</sc-if>
|
||||
<span style="font-family:var(--font-heading);font-size:17px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">{{ mTitle }}</span>
|
||||
<span style="margin-left:auto;display:flex;align-items:center;gap:8px;flex:none">
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:38px;height:38px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||
</button>
|
||||
<button onClick="{{ onCycleSeason }}" class="tag tag-accent-2" style="border-radius:999px;border:none;cursor:pointer;font-family:var(--font-body)" title="Switch season">{{ seasonShort }}</button>
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:38px;height:38px" onClick="{{ onUndo }}" disabled="{{ undoDisabled }}" title="Undo">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"></path><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"></path></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<sc-if value="{{ banner }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="padding:6px 14px;background:var(--color-accent-2-200);color:var(--color-accent-2-800);font-size:12px;font-weight:600;flex:none">{{ banner }}</div>
|
||||
</sc-if>
|
||||
<div style="flex:1;min-height:0;position:relative">
|
||||
<svg ref="{{ svgRef }}" width="100%" height="100%" style="{{ svgStyle }}" onPointerDown="{{ onCanvasDown }}" onPointerMove="{{ onCanvasMove }}" onPointerUp="{{ onCanvasUp }}" onPointerCancel="{{ onCanvasUp }}" onDragOver="{{ onDragOver }}" onDrop="{{ onDrop }}">
|
||||
<g style="{{ viewStyle }}">
|
||||
<rect x="0" y="0" width="{{ gw }}" height="{{ gh }}" rx="18" fill="var(--p-field)" stroke="var(--color-accent-2-500)" stroke-width="{{ borderW }}"></rect>
|
||||
<path d="{{ gridMinor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.06" fill="none"></path>
|
||||
<path d="{{ gridMajor }}" stroke="var(--p-grid-ink)" stroke-width="{{ hairW }}" opacity="0.12" fill="none"></path>
|
||||
<sc-for list="{{ objectsR }}" as="o" hint-placeholder-count="4">
|
||||
<g transform="{{ o.transform }}" opacity="{{ o.opacity }}" onPointerDown="{{ o.onDown }}" onDoubleClick="{{ o.onDbl }}">
|
||||
<sc-if value="{{ o.isRect }}" hint-placeholder-val="{{ true }}"><rect x="{{ o.x0 }}" y="{{ o.y0 }}" width="{{ o.w }}" height="{{ o.h }}" rx="{{ o.rr }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></rect></sc-if>
|
||||
<sc-if value="{{ o.isCircle }}" hint-placeholder-val="{{ false }}"><circle r="{{ o.r }}" fill="{{ o.fill }}" stroke="{{ o.stroke }}" stroke-width="{{ o.sw }}" stroke-dasharray="{{ o.dash }}"></circle></sc-if>
|
||||
</g>
|
||||
</sc-for>
|
||||
<sc-for list="{{ plopsR }}" as="p" hint-placeholder-count="0">
|
||||
<g transform="{{ p.transform }}" opacity="{{ p.opacity }}" onPointerDown="{{ p.onDown }}">
|
||||
<circle r="{{ p.r }}" fill="{{ p.fill }}" stroke="{{ p.stroke }}" stroke-width="{{ p.sw }}"></circle>
|
||||
</g>
|
||||
</sc-for>
|
||||
<g style="pointer-events:none">{{ labelsLayer }}</g>
|
||||
<sc-if value="{{ selO }}" hint-placeholder-val="{{ false }}">
|
||||
<g transform="{{ selO.transform }}" style="pointer-events:none">
|
||||
<sc-if value="{{ selO.isRect }}"><rect x="{{ selO.x0 }}" y="{{ selO.y0 }}" width="{{ selO.w }}" height="{{ selO.h }}" rx="{{ selO.rr }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></rect></sc-if>
|
||||
<sc-if value="{{ selO.isCircle }}"><circle r="{{ selO.r }}" fill="none" stroke="var(--color-accent)" stroke-width="{{ selO.sw }}" stroke-dasharray="{{ selO.dash }}"></circle></sc-if>
|
||||
</g>
|
||||
</sc-if>
|
||||
</g>
|
||||
</svg>
|
||||
<button class="btn btn-icon" style="position:absolute;bottom:12px;right:12px;border-radius:999px;width:40px;height:40px;background:var(--color-surface);border:1px solid var(--color-divider);box-shadow:var(--shadow-sm)" onClick="{{ zoomFit }}" title="Fit the garden">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ mPeek }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex:none;max-height:45%;display:flex;flex-direction:column;background:var(--color-neutral-100);border-top:1px solid var(--color-divider);border-radius:22px 22px 0 0;box-shadow:var(--shadow-lg)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:10px 14px 2px">
|
||||
<span style="font-family:var(--font-heading);font-size:15px">{{ mPeekTitle }}</span>
|
||||
<button class="btn btn-icon" style="border-radius:999px;margin-left:auto;width:32px;height:32px" style-hover="background:var(--color-accent-100)" onClick="{{ onPeekClose }}" title="Close">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:8px 16px 16px;display:flex;flex-direction:column;gap:10px">
|
||||
<sc-if value="{{ mPeekInsp }}" hint-placeholder-val="{{ false }}">
|
||||
<sc-if value="{{ insp.isObj }}">
|
||||
<input class="input" style="border-radius:999px;font-weight:700;font-size:16px" value="{{ insp.name }}" onChange="{{ insp.onRename }}">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span class="tag tag-neutral" style="border-radius:999px">{{ insp.kindLabel }}</span>
|
||||
<span style="font-size:13px;color:var(--p-ink-soft);font-weight:600">{{ insp.sizeText }}</span>
|
||||
</div>
|
||||
<sc-if value="{{ insp.rosterText }}"><div style="font-size:12.5px;color:var(--p-ink-soft);line-height:1.5">{{ insp.rosterText }}</div></sc-if>
|
||||
<div style="display:flex;gap:8px">
|
||||
<sc-if value="{{ insp.plantable }}"><button class="btn btn-primary" style="border-radius:999px;flex:1;min-height:44px" onClick="{{ insp.onOpen }}">Plant this</button></sc-if>
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:44px;height:44px" onClick="{{ insp.onRotate }}" title="Rotate 90°"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg></button>
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px;width:44px;height:44px" onClick="{{ insp.onDelete }}" title="Remove"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-700)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ insp.isPlop }}">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<svg width="18" height="18"><circle cx="9" cy="9" r="8" fill="{{ insp.plantColor }}"></circle></svg>
|
||||
<span style="font-family:var(--font-heading);font-size:17px">{{ insp.plantName }}</span>
|
||||
<span style="font-size:12.5px;color:var(--p-ink-soft);margin-left:auto">{{ insp.countText }}</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="border-radius:999px;min-height:44px" onClick="{{ insp.onDelete }}">Pull it out</button>
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
<sc-if value="{{ mPeekJournal }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;gap:6px">
|
||||
<input class="input" style="border-radius:999px;font-size:16px" placeholder="{{ jrPlaceholder }}" value="{{ jr }}" onChange="{{ onJr }}" onKeyDown="{{ onJrKey }}">
|
||||
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none;width:44px;height:44px" onClick="{{ onAddJr }}" title="Log it">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<sc-for list="{{ entriesR }}" as="e" hint-placeholder-count="3">
|
||||
<div style="background:var(--color-bg);border:1px solid var(--color-divider);border-radius:var(--radius-md);padding:11px 13px">
|
||||
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
|
||||
<span style="font-size:11.5px;font-weight:700;color:var(--color-accent-2-700)">{{ e.obj }}</span>
|
||||
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">{{ e.d }}</span>
|
||||
</div>
|
||||
<div style="font-size:13.5px;line-height:1.5">{{ e.txt }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</sc-if>
|
||||
<sc-if value="{{ mPeekChat }}" hint-placeholder-val="{{ false }}">
|
||||
<sc-for list="{{ msgsR }}" as="m" hint-placeholder-count="2">
|
||||
<div style="{{ m.style }}">{{ m.txt }}</div>
|
||||
</sc-for>
|
||||
<div style="display:flex;gap:6px">
|
||||
<input class="input" style="border-radius:999px;font-size:16px" placeholder="Ask about your garden…" value="{{ chat }}" onChange="{{ onChat }}" onKeyDown="{{ onChatKey }}">
|
||||
<button class="btn btn-primary btn-icon" style="border-radius:999px;flex:none;width:44px;height:44px" onClick="{{ onSend }}" title="Send">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"></path><path d="M22 2 11 13"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ mStrip }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex:none;display:flex;gap:8px;overflow-x:auto;padding:10px 12px;border-top:1px solid var(--color-divider);background:var(--color-bg)">
|
||||
<sc-if value="{{ mStripDone }}" hint-placeholder-val="{{ false }}">
|
||||
<button class="btn btn-primary" style="border-radius:999px;flex:none;min-height:44px" onClick="{{ onBack }}">Done</button>
|
||||
</sc-if>
|
||||
<sc-if value="{{ mStripKinds }}" hint-placeholder-val="{{ false }}">
|
||||
<sc-for list="{{ kindsR }}" as="k" hint-placeholder-count="7">
|
||||
<button onClick="{{ k.onClick }}" style="{{ k.mStyle }}">
|
||||
<svg width="30" height="24" viewBox="-20 -13 40 26" style="flex:none">
|
||||
<sc-if value="{{ k.isRect }}"><rect x="{{ k.mx0 }}" y="{{ k.my0 }}" width="{{ k.mw }}" height="{{ k.mh }}" rx="3" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></rect></sc-if>
|
||||
<sc-if value="{{ k.isCircle }}"><circle r="{{ k.mr }}" fill="{{ k.fill }}" stroke="{{ k.stroke }}" stroke-width="1.5" stroke-dasharray="{{ k.mdash }}"></circle></sc-if>
|
||||
</svg>
|
||||
<span style="font-size:13px;font-weight:700;white-space:nowrap">{{ k.label }}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</sc-if>
|
||||
<sc-if value="{{ mStripPlants }}" hint-placeholder-val="{{ false }}">
|
||||
<sc-for list="{{ plantsR }}" as="c" hint-placeholder-count="8">
|
||||
<button onClick="{{ c.onClick }}" style="{{ c.mStyle }}">
|
||||
<svg width="15" height="15" style="flex:none"><circle cx="7.5" cy="7.5" r="7.5" fill="{{ c.color }}"></circle></svg>
|
||||
<span style="font-size:13px;font-weight:700;white-space:nowrap">{{ c.name }}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<div style="flex:none;display:flex;gap:4px;padding:6px 8px calc(6px + env(safe-area-inset-bottom));background:var(--color-surface);border-top:1px solid var(--color-divider)">
|
||||
<sc-for list="{{ modesR }}" as="md">
|
||||
<button onClick="{{ md.onClick }}" style="{{ md.style }}">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ md.d1 }}"></path><path d="{{ md.d2 }}"></path><path d="{{ md.d3 }}"></path></svg>
|
||||
<span style="font-size:11px;font-weight:700">{{ md.label }}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"layout": {"editor": "enum", "options": ["auto", "phone", "desktop"], "default": "auto", "tsType": "'auto' | 'phone' | 'desktop'", "section": "Layout"}, "showGrid": {"editor": "boolean", "default": true, "tsType": "boolean", "section": "Canvas"}, "markers": {"editor": "enum", "options": ["monogram", "dot"], "default": "monogram", "tsType": "'monogram' | 'dot'", "section": "Canvas"}}">
|
||||
class Component extends DCLogic {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.svgRef = React.createRef(); this.rootRef = React.createRef();
|
||||
this.GW = 1219; this.GH = 853; this.nid = 100; this.pts = new Map();
|
||||
this.PLANTS = [
|
||||
{ id: 'garlic', name: 'Garlic', letter: 'G', color: '#97a97c', spacing: 15 },
|
||||
{ id: 'tomato', name: 'Tomato', letter: 'T', color: '#c8553d', spacing: 60 },
|
||||
{ id: 'cucumber', name: 'Cucumber', letter: 'C', color: '#6f8f4f', spacing: 30 },
|
||||
{ id: 'watermelon', name: 'Watermelon', letter: 'W', color: '#46683c', spacing: 90 },
|
||||
{ id: 'basil', name: 'Basil', letter: 'B', color: '#5f8f45', spacing: 25 },
|
||||
{ id: 'pepper', name: 'Pepper', letter: 'P', color: '#b2622d', spacing: 45 },
|
||||
{ id: 'marigold', name: 'Marigold', letter: 'Ma', color: '#d9912f', spacing: 20 },
|
||||
{ id: 'melon', name: 'Melon', letter: 'Me', color: '#c2913a', spacing: 90 },
|
||||
];
|
||||
this.KINDS = [
|
||||
{ kind: 'bed', label: 'Bed', shape: 'rect', w: 91, h: 183, plantable: true },
|
||||
{ kind: 'grow_bag', label: 'Grow bag', shape: 'circle', w: 40, h: 40, plantable: true },
|
||||
{ kind: 'container', label: 'Container', shape: 'circle', w: 60, h: 60, plantable: true },
|
||||
{ kind: 'in_ground', label: 'In-ground', shape: 'rect', w: 200, h: 200, plantable: true },
|
||||
{ kind: 'tree', label: 'Tree', shape: 'circle', w: 300, h: 300, plantable: false },
|
||||
{ kind: 'path', label: 'Path', shape: 'rect', w: 100, h: 300, plantable: false },
|
||||
{ kind: 'structure', label: 'Structure', shape: 'rect', w: 200, h: 200, plantable: false },
|
||||
];
|
||||
const o = (id, kind, name, shape, x, y, w, h) => ({ id, kind, name, shape, x, y, w, h, rot: 0, plantable: this.KINDS.find(k => k.kind === kind).plantable });
|
||||
const objects = [
|
||||
o(10, 'path', 'Walkway', 'rect', 560, 285, 1010, 75),
|
||||
o(1, 'bed', 'Bed 1', 'rect', 110, 140, 91, 183), o(2, 'bed', 'Bed 2', 'rect', 320, 140, 91, 183),
|
||||
o(3, 'bed', 'Bed 3', 'rect', 530, 140, 91, 183), o(4, 'bed', 'Bed 4', 'rect', 740, 140, 91, 183),
|
||||
o(5, 'bed', 'Bed 5', 'rect', 950, 140, 91, 183),
|
||||
o(6, 'bed', 'Long bed A', 'rect', 190, 392, 244, 61), o(7, 'bed', 'Long bed B', 'rect', 190, 502, 244, 61),
|
||||
o(8, 'bed', 'Long bed C', 'rect', 190, 612, 244, 61), o(9, 'bed', 'Long bed D', 'rect', 190, 722, 244, 61),
|
||||
o(11, 'grow_bag', 'Bag 1', 'circle', 1090, 110, 40, 40), o(12, 'grow_bag', 'Bag 2', 'circle', 1090, 165, 40, 40),
|
||||
o(13, 'grow_bag', 'Bag 3', 'circle', 1090, 220, 40, 40),
|
||||
o(14, 'container', 'Bucket 1', 'circle', 1160, 110, 30, 30), o(15, 'container', 'Bucket 2', 'circle', 1160, 165, 30, 30),
|
||||
];
|
||||
let plops = [];
|
||||
const grid = (objId, plantId, w, h, sp) => {
|
||||
const cols = Math.max(1, Math.floor(w / sp)), rows = Math.max(1, Math.floor(h / sp));
|
||||
for (let i = 0; i < cols; i++) for (let j = 0; j < rows; j++)
|
||||
plops.push({ id: this.nid++, objId, plantId, lx: (i - (cols - 1) / 2) * sp, ly: (j - (rows - 1) / 2) * sp, r: sp / 2 });
|
||||
};
|
||||
grid(6, 'garlic', 244, 61, 15); grid(1, 'tomato', 91, 183, 60); grid(3, 'watermelon', 91, 183, 90);
|
||||
grid(4, 'basil', 91, 183, 25); grid(7, 'pepper', 244, 61, 45); grid(8, 'marigold', 244, 61, 20);
|
||||
plops.push({ id: this.nid++, objId: 2, plantId: 'cucumber', lx: 0, ly: -48, r: 38 }, { id: this.nid++, objId: 2, plantId: 'cucumber', lx: 0, ly: 48, r: 38 });
|
||||
[11, 12, 13].forEach(b => plops.push({ id: this.nid++, objId: b, plantId: 'melon', lx: 0, ly: 0, r: 15 }));
|
||||
const min = [], maj = [], FT = 30.48;
|
||||
for (let x = FT; x < this.GW; x += FT) (Math.round(x / FT) % 5 ? min : maj).push(`M${x.toFixed(1)} 0V${this.GH}`);
|
||||
for (let y = FT; y < this.GH; y += FT) (Math.round(y / FT) % 5 ? min : maj).push(`M0 ${y.toFixed(1)}H${this.GW}`);
|
||||
this.gridMinor = min.join(''); this.gridMajor = maj.join('');
|
||||
this.state = {
|
||||
vw: 0, tx: 40, ty: 40, s: 0.5, anim: false, sel: null, focus: null, armK: null, armP: null, ghost: null,
|
||||
objects, plops, undo: [], tab: 'plot', mode: 'build', season: '2026', q: '', jr: '', chat: '',
|
||||
entries: [
|
||||
{ d: 'Aug 14', obj: 'Long bed A', txt: 'Garlic tips yellowing on the north end — eased off the watering.' },
|
||||
{ d: 'Aug 9', obj: 'Bed 3', txt: 'First watermelon set! Ordered a sling.' },
|
||||
{ d: 'Aug 2', obj: 'Bag 2', txt: 'Melon vine escaping the bag — trained it along the fence.' },
|
||||
],
|
||||
msgs: [
|
||||
{ who: 'u', txt: 'What can I follow the garlic with?' },
|
||||
{ who: 'a', txt: 'Long bed A frees up mid-July. Bush beans, carrots, or fall brassica starts all fit that window. Want me to sketch beans in?' },
|
||||
],
|
||||
};
|
||||
}
|
||||
layoutProp() { return this.props.layout ?? 'auto'; }
|
||||
isMobileNow() {
|
||||
const lp = this.layoutProp();
|
||||
return lp === 'phone' || (lp !== 'desktop' && this.state.vw > 0 && this.state.vw < 760);
|
||||
}
|
||||
styleFor(kind) {
|
||||
return {
|
||||
bed: { fill: 'var(--p-bed-fill)', stroke: 'var(--p-bed-stroke)' }, in_ground: { fill: 'var(--p-ing-fill)', stroke: 'var(--p-ing-stroke)', dash: '10 7' },
|
||||
path: { fill: 'var(--p-path-fill)', stroke: 'var(--p-path-stroke)', dash: '3 8' }, grow_bag: { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' },
|
||||
container: { fill: 'var(--p-bkt-fill)', stroke: 'var(--p-bkt-stroke)' }, tree: { fill: 'var(--p-tree-fill)', stroke: 'var(--p-tree-stroke)', dash: '12 8' },
|
||||
structure: { fill: 'var(--p-str-fill)', stroke: 'var(--p-str-stroke)' },
|
||||
}[kind] || { fill: '#ddd', stroke: '#999' };
|
||||
}
|
||||
plant(id) { return this.PLANTS.find(p => p.id === id); }
|
||||
obj(id) { return this.state.objects.find(o => o.id === id); }
|
||||
fmt(cm) { const i = Math.round(cm / 2.54); const ft = Math.floor(i / 12), r = i - ft * 12; return ft ? (r ? ft + '\u2032' + r + '\u2033' : ft + '\u2032') : r + '\u2033'; }
|
||||
cnt(p) { const sp = this.plant(p.plantId).spacing; return Math.max(1, Math.round(Math.PI * p.r * p.r / (sp * sp))); }
|
||||
get readOnly() { return this.state.season === '2025'; }
|
||||
componentDidMount() {
|
||||
const measure = () => {
|
||||
const w = this.rootRef.current ? this.rootRef.current.clientWidth : 0;
|
||||
const wasM = this.isMobileNow();
|
||||
this.setState({ vw: w }, () => {
|
||||
const el = this.svgRef.current;
|
||||
const big = el && (Math.abs(el.clientWidth - (this._fitW || 0)) > 60 || Math.abs(el.clientHeight - (this._fitH || 0)) > 60);
|
||||
if (this.isMobileNow() !== wasM || big) this.zoomFit();
|
||||
});
|
||||
};
|
||||
measure();
|
||||
this.ro = new ResizeObserver(() => measure());
|
||||
this.rootRef.current && this.ro.observe(this.rootRef.current);
|
||||
setTimeout(() => this.zoomFit(), 30);
|
||||
this.wheelHooked = null;
|
||||
this.hookWheel();
|
||||
this.keyN = (e) => {
|
||||
if (/input|textarea/i.test(e.target.tagName)) return;
|
||||
if (e.key === 'Escape') {
|
||||
const st = this.state;
|
||||
if (st.armK || st.armP) this.setState({ armK: null, armP: null, ghost: null });
|
||||
else if (st.sel) this.setState({ sel: null });
|
||||
else if (st.focus) this.back();
|
||||
}
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && this.state.sel) this.deleteSel();
|
||||
};
|
||||
window.addEventListener('keydown', this.keyN);
|
||||
this.themeMount();
|
||||
}
|
||||
componentDidUpdate() { this.hookWheel(); }
|
||||
hookWheel() {
|
||||
const el = this.svgRef.current;
|
||||
if (!el || this.wheelHooked === el) return;
|
||||
this.wheelN = (e) => {
|
||||
e.preventDefault();
|
||||
const r = el.getBoundingClientRect(), { tx, ty, s } = this.state;
|
||||
const ns = Math.min(8, Math.max(0.12, s * Math.exp(-e.deltaY * 0.0016)));
|
||||
const px = e.clientX - r.left, py = e.clientY - r.top;
|
||||
this.setState({ s: ns, tx: px - (px - tx) / s * ns, ty: py - (py - ty) / s * ns, anim: false });
|
||||
};
|
||||
el.addEventListener('wheel', this.wheelN, { passive: false });
|
||||
this.wheelHooked = el;
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.ro && this.ro.disconnect();
|
||||
this._unwatchTheme && this._unwatchTheme();
|
||||
window.removeEventListener('keydown', this.keyN);
|
||||
}
|
||||
world(e) { const r = this.svgRef.current.getBoundingClientRect(); return { x: (e.clientX - r.left - this.state.tx) / this.state.s, y: (e.clientY - r.top - this.state.ty) / this.state.s }; }
|
||||
snap(v) { return Math.round(v / 7.62) * 7.62; }
|
||||
thresh(e) { return e && e.pointerType === 'touch' ? 7 : 3; }
|
||||
pushUndo(label, before) {
|
||||
const b = before || { objects: this.state.objects, plops: this.state.plops };
|
||||
this.setState(st => ({ undo: [...st.undo.slice(-29), { label, when: new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), objects: b.objects, plops: b.plops }] }));
|
||||
}
|
||||
onUndo = () => { const u = this.state.undo; if (!u.length) return; const last = u[u.length - 1]; this.setState({ objects: last.objects, plops: last.plops, undo: u.slice(0, -1), sel: null, ghost: null }); };
|
||||
unanim() { clearTimeout(this._at); this._at = setTimeout(() => this.setState({ anim: false }), 520); }
|
||||
zoomFit = () => {
|
||||
const el = this.svgRef.current; if (!el) return;
|
||||
const pad = this.isMobileNow() ? 20 : 40;
|
||||
const s = Math.min((el.clientWidth - pad * 2) / this.GW, (el.clientHeight - pad * 2) / this.GH);
|
||||
if (!isFinite(s) || s <= 0) return;
|
||||
this._fitW = el.clientWidth; this._fitH = el.clientHeight;
|
||||
this.setState({ s, tx: (el.clientWidth - this.GW * s) / 2, ty: (el.clientHeight - this.GH * s) / 2, anim: true }); this.unanim();
|
||||
};
|
||||
zoomBy(f) {
|
||||
const el = this.svgRef.current, { tx, ty, s } = this.state;
|
||||
const ns = Math.min(8, Math.max(0.12, s * f)), px = el.clientWidth / 2, py = el.clientHeight / 2;
|
||||
this.setState({ s: ns, tx: px - (px - tx) / s * ns, ty: py - (py - ty) / s * ns, anim: true }); this.unanim();
|
||||
}
|
||||
zoomIn = () => this.zoomBy(1.45); zoomOut = () => this.zoomBy(1 / 1.45);
|
||||
focusObj(o) {
|
||||
const el = this.svgRef.current, m = this.isMobileNow();
|
||||
const bw = o.rot % 180 ? o.h : o.w, bh = o.rot % 180 ? o.w : o.h;
|
||||
const s = Math.min(6, Math.min(el.clientWidth / (bw * (m ? 1.35 : 2.1)), el.clientHeight / (bh * (m ? 1.45 : 1.7))));
|
||||
this.setState({ focus: o.id, sel: m ? null : { t: 'obj', id: o.id }, armK: null, ghost: null, tab: 'plot', mode: m ? 'plants' : this.state.mode, anim: true, s, tx: el.clientWidth / 2 - o.x * s, ty: el.clientHeight / 2 - o.y * s + (m ? 0 : 10) }); this.unanim();
|
||||
}
|
||||
back = () => { this.setState({ focus: null, armP: null, sel: null, q: '', mode: this.isMobileNow() ? 'build' : this.state.mode }); this.zoomFit(); };
|
||||
toLocal(o, w) { const a = -o.rot * Math.PI / 180, dx = w.x - o.x, dy = w.y - o.y; return { x: dx * Math.cos(a) - dy * Math.sin(a), y: dx * Math.sin(a) + dy * Math.cos(a) }; }
|
||||
objAt(w) {
|
||||
const os = this.state.objects;
|
||||
for (let i = os.length - 1; i >= 0; i--) {
|
||||
const o = os[i], l = this.toLocal(o, w);
|
||||
if (o.shape === 'circle' ? Math.hypot(l.x, l.y) <= o.w / 2 : Math.abs(l.x) <= o.w / 2 && Math.abs(l.y) <= o.h / 2) return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
placeObj(w) {
|
||||
if (this.readOnly) return;
|
||||
const kd = this.KINDS.find(k => k.kind === this.state.armK);
|
||||
this.pushUndo('Added a ' + kd.label.toLowerCase());
|
||||
const id = this.nid++;
|
||||
const n = this.state.objects.filter(x => x.kind === kd.kind).length + 1;
|
||||
const no = { id, kind: kd.kind, name: kd.label + ' ' + n, shape: kd.shape, x: this.snap(w.x), y: this.snap(w.y), w: kd.w, h: kd.h, rot: 0, plantable: kd.plantable };
|
||||
this.setState(st => ({ objects: [...st.objects, no], armK: null, ghost: null, sel: { t: 'obj', id } }));
|
||||
}
|
||||
placePlop(objId, l) {
|
||||
if (this.readOnly) return;
|
||||
const o = this.obj(objId), pl = this.plant(this.state.armP || this._dropPlant);
|
||||
if (!o || !pl) return;
|
||||
const r = pl.spacing / 2;
|
||||
const cx = Math.max(-(o.w / 2 - r / 2), Math.min(o.w / 2 - r / 2, l.x)), cy = Math.max(-(o.h / 2 - r / 2), Math.min(o.h / 2 - r / 2, l.y));
|
||||
this.pushUndo('Planted ' + pl.name.toLowerCase() + ' in ' + o.name);
|
||||
const id = this.nid++;
|
||||
this.setState(st => ({ plops: [...st.plops, { id, objId, plantId: pl.id, lx: cx, ly: cy, r }], sel: this.isMobileNow() ? st.sel : { t: 'plop', id } }));
|
||||
}
|
||||
pinchStart() {
|
||||
const [a, b] = [...this.pts.values()];
|
||||
this.drag = null;
|
||||
this.pinch = { d0: Math.hypot(a.x - b.x, a.y - b.y), cx0: (a.x + b.x) / 2, cy0: (a.y + b.y) / 2, s0: this.state.s, tx0: this.state.tx, ty0: this.state.ty };
|
||||
}
|
||||
onCanvasDown = (e) => {
|
||||
const r = this.svgRef.current.getBoundingClientRect();
|
||||
this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
|
||||
try { this.svgRef.current.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
if (this.pts.size === 2) { this.pinchStart(); return; }
|
||||
const st = this.state, w = this.world(e);
|
||||
if (st.armK) { this.placeObj(w); return; }
|
||||
if (st.armP) {
|
||||
const o = st.focus ? this.obj(st.focus) : this.objAt(w);
|
||||
if (o && o.plantable) this.placePlop(o.id, this.toLocal(o, w));
|
||||
return;
|
||||
}
|
||||
if (!this.drag) this.drag = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.tx, ty: st.ty, moved: false };
|
||||
};
|
||||
objDown(o) {
|
||||
return (e) => {
|
||||
e.stopPropagation();
|
||||
const r = this.svgRef.current.getBoundingClientRect();
|
||||
this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
|
||||
try { this.svgRef.current.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
if (this.pts.size === 2) { this.pinchStart(); return; }
|
||||
const st = this.state;
|
||||
if (st.armK) { this.placeObj(this.world(e)); return; }
|
||||
if (st.armP) { if (o.plantable) this.placePlop(o.id, this.toLocal(o, this.world(e))); return; }
|
||||
if (st.focus && o.id !== st.focus) return;
|
||||
if (this.readOnly) { this.setState({ sel: { t: 'obj', id: o.id } }); return; }
|
||||
const w = this.world(e);
|
||||
this.drag = { t: 'obj', id: o.id, ox: o.x, oy: o.y, sx: w.x, sy: w.y, moved: false, th: this.thresh(e), before: { objects: st.objects, plops: st.plops } };
|
||||
};
|
||||
}
|
||||
plopDown(p) {
|
||||
return (e) => {
|
||||
const st = this.state;
|
||||
if (st.armK || st.armP) return;
|
||||
e.stopPropagation();
|
||||
if (st.focus !== p.objId) { this.objDown(this.obj(p.objId))(e); return; }
|
||||
const r = this.svgRef.current.getBoundingClientRect();
|
||||
this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
|
||||
if (this.readOnly) { this.setState({ sel: { t: 'plop', id: p.id } }); return; }
|
||||
const w = this.world(e), o = this.obj(p.objId), l = this.toLocal(o, w);
|
||||
this.drag = { t: 'plop', id: p.id, ox: p.lx, oy: p.ly, sx: l.x, sy: l.y, moved: false, th: this.thresh(e), before: { objects: st.objects, plops: st.plops } };
|
||||
};
|
||||
}
|
||||
onCanvasMove = (e) => {
|
||||
const r = this.svgRef.current.getBoundingClientRect();
|
||||
if (this.pts.has(e.pointerId)) this.pts.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top });
|
||||
if (this.pinch && this.pts.size >= 2) {
|
||||
const [a, b] = [...this.pts.values()], p = this.pinch;
|
||||
const d1 = Math.hypot(a.x - b.x, a.y - b.y), cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
|
||||
const ns = Math.min(8, Math.max(0.12, p.s0 * (d1 / Math.max(1, p.d0))));
|
||||
const wx = (p.cx0 - p.tx0) / p.s0, wy = (p.cy0 - p.ty0) / p.s0;
|
||||
this.setState({ s: ns, tx: cx - wx * ns, ty: cy - wy * ns, anim: false });
|
||||
return;
|
||||
}
|
||||
const st = this.state;
|
||||
if (!this.drag) { if (st.armK && e.pointerType !== 'touch') { const w = this.world(e); this.setState({ ghost: { x: this.snap(w.x), y: this.snap(w.y) } }); } return; }
|
||||
const d = this.drag;
|
||||
if (d.t === 'pan') {
|
||||
if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > (d.th || 3)) d.moved = true;
|
||||
this.setState({ tx: d.tx + e.clientX - d.sx, ty: d.ty + e.clientY - d.sy, anim: false });
|
||||
} else if (d.t === 'obj') {
|
||||
const w = this.world(e), nx = this.snap(d.ox + w.x - d.sx), ny = this.snap(d.oy + w.y - d.sy);
|
||||
if (Math.hypot(w.x - d.sx, w.y - d.sy) > (d.th || 3)) d.moved = true;
|
||||
this.setState(s2 => ({ objects: s2.objects.map(o => o.id === d.id ? { ...o, x: nx, y: ny } : o) }));
|
||||
} else if (d.t === 'plop') {
|
||||
const p0 = this.state.plops.find(p => p.id === d.id); if (!p0) return;
|
||||
const o = this.obj(p0.objId), l = this.toLocal(o, this.world(e));
|
||||
const nx = Math.max(-(o.w / 2 - p0.r / 2), Math.min(o.w / 2 - p0.r / 2, d.ox + l.x - d.sx));
|
||||
const ny = Math.max(-(o.h / 2 - p0.r / 2), Math.min(o.h / 2 - p0.r / 2, d.oy + l.y - d.sy));
|
||||
if (Math.hypot(l.x - d.sx, l.y - d.sy) > (d.th || 3)) d.moved = true;
|
||||
this.setState(s2 => ({ plops: s2.plops.map(p => p.id === d.id ? { ...p, lx: nx, ly: ny } : p) }));
|
||||
}
|
||||
};
|
||||
onCanvasUp = (e) => {
|
||||
this.pts.delete(e.pointerId);
|
||||
if (this.pinch) { if (this.pts.size < 2) this.pinch = null; return; }
|
||||
const d = this.drag; this.drag = null;
|
||||
if (!d) return;
|
||||
if (d.t === 'pan') { if (!d.moved) this.setState({ sel: null }); return; }
|
||||
if (!d.moved) { this.setState({ sel: { t: d.t, id: d.id }, tab: 'plot' }); return; }
|
||||
this.pushUndo(d.t === 'obj' ? 'Moved ' + (this.obj(d.id) || {}).name : 'Moved a planting', d.before);
|
||||
};
|
||||
deleteSel = () => {
|
||||
if (this.readOnly) return;
|
||||
const { sel } = this.state; if (!sel) return;
|
||||
this.pushUndo(sel.t === 'obj' ? 'Removed ' + (this.obj(sel.id) || {}).name : 'Pulled a planting');
|
||||
if (sel.t === 'obj') this.setState(st => ({ objects: st.objects.filter(o => o.id !== sel.id), plops: st.plops.filter(p => p.objId !== sel.id), sel: null, focus: st.focus === sel.id ? null : st.focus }));
|
||||
else this.setState(st => ({ plops: st.plops.filter(p => p.id !== sel.id), sel: null }));
|
||||
};
|
||||
onDragOver = (e) => e.preventDefault();
|
||||
onDrop = (e) => {
|
||||
e.preventDefault();
|
||||
const data = e.dataTransfer.getData('text/plain'); if (!data) return;
|
||||
const [t, id] = data.split(':'), w = this.world(e);
|
||||
if (t === 'kind') this.setState({ armK: id }, () => this.placeObj(w));
|
||||
if (t === 'plant') { const o = this.objAt(w); if (o && o.plantable) { this._dropPlant = id; this.placePlop(o.id, this.toLocal(o, w)); this._dropPlant = null; } }
|
||||
};
|
||||
addJournal = () => {
|
||||
const txt = this.state.jr.trim(); if (!txt) return;
|
||||
const sel = this.state.sel, target = sel && sel.t === 'obj' && this.obj(sel.id) ? this.obj(sel.id).name : (this.state.focus ? this.obj(this.state.focus).name : 'Garden');
|
||||
this.setState(st => ({ entries: [{ d: 'Aug 22', obj: target, txt }, ...st.entries], jr: '' }));
|
||||
};
|
||||
sendChat = () => {
|
||||
const txt = this.state.chat.trim(); if (!txt) return;
|
||||
this.setState(st => ({ msgs: [...st.msgs, { who: 'u', txt }], chat: '' }));
|
||||
setTimeout(() => this.setState(st => ({ msgs: [...st.msgs, { who: 'a', txt: '(demo) In the real app I act on your live garden — and each of my turns lands as one undoable step in History.' }] })), 550);
|
||||
};
|
||||
themeMount() {
|
||||
const PT = window.PansyTheme; if (!PT) return;
|
||||
this.setState({ themePref: PT.get() });
|
||||
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||
}
|
||||
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||
themeVals() {
|
||||
const p = this.state.themePref || 'system';
|
||||
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||
}
|
||||
renderVals() {
|
||||
const st = this.state, s = st.s;
|
||||
const isMobile = this.isMobileNow(), isDesktop = !isMobile;
|
||||
const showGrid = this.props.showGrid ?? true;
|
||||
const markers = this.props.markers ?? 'monogram';
|
||||
const focused = st.focus ? this.obj(st.focus) : null;
|
||||
const objectsR = st.objects.map(o => {
|
||||
const sty = this.styleFor(o.kind), isSel = st.sel && st.sel.t === 'obj' && st.sel.id === o.id;
|
||||
return {
|
||||
key: o.id, isRect: o.shape === 'rect', isCircle: o.shape === 'circle',
|
||||
x0: -o.w / 2, y0: -o.h / 2, w: o.w, h: o.h, rr: Math.min(14, o.w * 0.14), r: o.w / 2,
|
||||
fill: sty.fill, stroke: isSel ? 'var(--color-accent)' : sty.stroke, sw: (isSel ? 3.5 : 2.5) / s, dash: sty.dash,
|
||||
transform: `translate(${o.x} ${o.y}) rotate(${o.rot})`,
|
||||
opacity: st.focus && o.id !== st.focus ? 0.3 : 1,
|
||||
onDown: this.objDown(o), onDbl: o.plantable ? () => this.focusObj(o) : undefined,
|
||||
};
|
||||
});
|
||||
const plopsR = st.plops.map(p => {
|
||||
const o = this.obj(p.objId); if (!o) return null;
|
||||
const pl = this.plant(p.plantId), a = o.rot * Math.PI / 180;
|
||||
const wx = o.x + p.lx * Math.cos(a) - p.ly * Math.sin(a), wy = o.y + p.lx * Math.sin(a) + p.ly * Math.cos(a);
|
||||
const isSel = st.sel && st.sel.t === 'plop' && st.sel.id === p.id;
|
||||
return {
|
||||
key: p.id, transform: `translate(${wx} ${wy})`, r: p.r, fill: pl.color,
|
||||
stroke: isSel ? '#fffaf1' : 'none', sw: 2.5 / s,
|
||||
opacity: st.focus && p.objId !== st.focus ? 0.22 : 0.94,
|
||||
gStyle: { cursor: st.focus === p.objId ? 'grab' : 'inherit' },
|
||||
onDown: this.plopDown(p),
|
||||
};
|
||||
}).filter(Boolean);
|
||||
const T = (props2, str) => React.createElement('text', props2, str);
|
||||
const labels = [];
|
||||
st.objects.forEach(o => {
|
||||
if (!o.name || Math.max(o.w, o.h) * s <= 54 || (st.focus && st.focus !== o.id) || s <= 0.28) return;
|
||||
const a = o.rot * Math.PI / 180, bh = (Math.abs(o.w * Math.sin(a)) + Math.abs(o.h * Math.cos(a))) / 2;
|
||||
labels.push(o.plantable
|
||||
? T({ key: 'ol' + o.id, x: o.x, y: o.y - 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)
|
||||
: T({ key: 'ol' + o.id, x: o.x, y: o.y, textAnchor: 'middle', dominantBaseline: 'central', fontSize: 13 / s, fill: 'var(--p-ink-mute)', style: { fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.06em' } }, o.name));
|
||||
});
|
||||
if (markers === 'monogram') st.plops.forEach(p => {
|
||||
const o = this.obj(p.objId); if (!o || (st.focus && p.objId !== st.focus)) return;
|
||||
const pl = this.plant(p.plantId), a = o.rot * Math.PI / 180;
|
||||
const wx = o.x + p.lx * Math.cos(a) - p.ly * Math.sin(a), wy = o.y + p.lx * Math.sin(a) + p.ly * Math.cos(a);
|
||||
if (p.r * s >= 9) labels.push(T({ key: 'pl' + p.id, x: wx, y: wy, textAnchor: 'middle', dominantBaseline: 'central', fontSize: p.r * 1.05, fill: '#fffaf1', style: { fontFamily: 'var(--font-heading)' } }, pl.letter));
|
||||
if (p.r * s >= 34) labels.push(T({ key: 'pn' + p.id, x: wx, y: wy + p.r + 13 / s, textAnchor: 'middle', fontSize: 11 / s, fill: 'var(--p-ink-strong)', style: { fontFamily: 'var(--font-body)', fontWeight: 600 } }, pl.name));
|
||||
});
|
||||
let selO = null, insp = null;
|
||||
if (st.sel && st.sel.t === 'obj') {
|
||||
const o = this.obj(st.sel.id);
|
||||
if (o) {
|
||||
const a = o.rot * Math.PI / 180, bh = (Math.abs(o.w * Math.sin(a)) + Math.abs(o.h * Math.cos(a))) / 2;
|
||||
labels.push(T({ key: 'sz', x: o.x, y: o.y + bh + 22 / s, textAnchor: 'middle', fontSize: 12.5 / s, fill: 'var(--color-accent-700)', style: { fontFamily: 'var(--font-body)', fontWeight: 700 } }, this.fmt(o.w) + ' × ' + this.fmt(o.h)));
|
||||
selO = {
|
||||
transform: `translate(${o.x} ${o.y}) rotate(${o.rot})`, isRect: o.shape === 'rect', isCircle: o.shape === 'circle',
|
||||
x0: -o.w / 2 - 7 / s, y0: -o.h / 2 - 7 / s, w: o.w + 14 / s, h: o.h + 14 / s, r: o.w / 2 + 7 / s, rr: 16,
|
||||
sw: 1.8 / s, dash: `${8 / s} ${6 / s}`,
|
||||
};
|
||||
const roster = {};
|
||||
st.plops.filter(p => p.objId === o.id).forEach(p => { const n = this.plant(p.plantId).name; roster[n] = (roster[n] || 0) + this.cnt(p); });
|
||||
const names = Object.entries(roster).map(([n, c]) => c + ' ' + n.toLowerCase()).join(' · ');
|
||||
insp = {
|
||||
isObj: true, name: o.name, kindLabel: this.KINDS.find(k => k.kind === o.kind).label,
|
||||
sizeText: this.fmt(o.w) + ' × ' + this.fmt(o.h), plantable: o.plantable && st.focus !== o.id,
|
||||
rosterText: names ? 'Growing: ' + names : (o.plantable ? 'Nothing planted yet.' : ''),
|
||||
onRename: (e) => this.setState(s2 => ({ objects: s2.objects.map(x => x.id === o.id ? { ...x, name: e.target.value } : x) })),
|
||||
onRotate: () => { if (this.readOnly) return; this.pushUndo('Rotated ' + o.name); this.setState(s2 => ({ objects: s2.objects.map(x => x.id === o.id ? { ...x, rot: (x.rot + 90) % 360 } : x) })); },
|
||||
onDelete: this.deleteSel, onOpen: () => this.focusObj(o),
|
||||
};
|
||||
}
|
||||
} else if (st.sel && st.sel.t === 'plop') {
|
||||
const p = st.plops.find(x => x.id === st.sel.id);
|
||||
if (p) {
|
||||
const pl = this.plant(p.plantId), c = this.cnt(p);
|
||||
insp = { isPlop: true, plantName: pl.name, plantColor: pl.color, countText: c + (c === 1 ? ' plant' : ' plants'), plopSize: this.fmt(p.r * 2), onDelete: this.deleteSel };
|
||||
}
|
||||
}
|
||||
let summary = null;
|
||||
if (!insp) {
|
||||
const roster = {};
|
||||
st.plops.forEach(p => {
|
||||
const pl = this.plant(p.plantId), o = this.obj(p.objId); if (!o) return;
|
||||
if (!roster[pl.id]) roster[pl.id] = { name: pl.name, color: pl.color, n: 0, beds: new Set() };
|
||||
roster[pl.id].n += this.cnt(p); roster[pl.id].beds.add(o.name);
|
||||
});
|
||||
const beds = st.objects.filter(o => o.kind === 'bed').length, bags = st.objects.filter(o => o.kind === 'grow_bag').length, bkt = st.objects.filter(o => o.kind === 'container').length;
|
||||
summary = {
|
||||
countsText: `${beds} beds · ${bags} grow bags · ${bkt} buckets · ${st.plops.length} plantings`,
|
||||
roster: Object.values(roster).map((r, i) => ({ key: i, name: r.name, color: r.color, where: r.n + ' in ' + [...r.beds][0] + ([...r.beds].length > 1 ? ' +' + ([...r.beds].length - 1) : '') })),
|
||||
};
|
||||
}
|
||||
const kindsR = this.KINDS.map(k => {
|
||||
const sty = this.styleFor(k.kind), f = 24 / Math.max(k.w, k.h), armed = st.armK === k.kind;
|
||||
return {
|
||||
key: k.kind, label: k.label, sizeText: this.fmt(k.w) + ' × ' + this.fmt(k.h), isRect: k.shape === 'rect', isCircle: k.shape === 'circle',
|
||||
mx0: -k.w * f / 2, my0: -k.h * f / 2, mw: k.w * f, mh: k.h * f, mr: k.w * f / 2,
|
||||
fill: sty.fill, stroke: sty.stroke, mdash: sty.dash ? '3 3' : undefined,
|
||||
btnStyle: { display: 'flex', alignItems: 'center', gap: '10px', padding: '7px 10px', cursor: 'pointer', textAlign: 'left', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'transparent', border: armed ? '1px solid var(--color-accent-400)' : '1px solid transparent' },
|
||||
mStyle: { display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 14px', minHeight: '44px', flex: 'none', cursor: 'pointer', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'var(--color-neutral-100)', border: armed ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
|
||||
onClick: () => this.setState({ armK: armed ? null : k.kind, armP: null, sel: null, ghost: null }),
|
||||
onDrag: (e) => e.dataTransfer.setData('text/plain', 'kind:' + k.kind),
|
||||
};
|
||||
});
|
||||
const ql = st.q.trim().toLowerCase();
|
||||
const plantsR = this.PLANTS.filter(pl => !ql || pl.name.toLowerCase().includes(ql)).map(pl => {
|
||||
const armed = st.armP === pl.id;
|
||||
return {
|
||||
key: pl.id, name: pl.name, color: pl.color, spacingText: this.fmt(pl.spacing) + ' apart',
|
||||
btnStyle: { display: 'flex', alignItems: 'center', gap: '9px', padding: '8px 12px', cursor: 'pointer', textAlign: 'left', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'var(--color-bg)', border: armed ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
|
||||
mStyle: { display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 14px', minHeight: '44px', flex: 'none', cursor: 'pointer', borderRadius: '999px', fontFamily: 'var(--font-body)', background: armed ? 'var(--color-accent-200)' : 'var(--color-neutral-100)', border: armed ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
|
||||
onClick: () => this.setState({ armP: armed ? null : pl.id, armK: null, sel: null }),
|
||||
onDrag: (e) => e.dataTransfer.setData('text/plain', 'plant:' + pl.id),
|
||||
};
|
||||
});
|
||||
let ghostR = null;
|
||||
if (st.ghost && st.armK) {
|
||||
const kd = this.KINDS.find(k => k.kind === st.armK), sty = this.styleFor(kd.kind);
|
||||
ghostR = { transform: `translate(${st.ghost.x} ${st.ghost.y})`, isRect: kd.shape === 'rect', isCircle: kd.shape === 'circle', x0: -kd.w / 2, y0: -kd.h / 2, w: kd.w, h: kd.h, r: kd.w / 2, fill: sty.fill, sw: 2 / s, dash: `${7 / s} ${5 / s}` };
|
||||
}
|
||||
const tabs = [['plot', 'Plot'], ['journal', 'Journal'], ['history', 'History'], ['chat', 'Assistant']];
|
||||
const tabsR = tabs.map(([id, label]) => ({
|
||||
key: id, label,
|
||||
style: { flex: 1, padding: '8px 4px', cursor: 'pointer', border: 'none', borderRadius: '999px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.tab === id ? 'var(--color-accent-200)' : 'transparent', color: st.tab === id ? 'var(--color-accent-800)' : 'var(--p-ink-soft)' },
|
||||
onClick: () => this.setState({ tab: id }),
|
||||
}));
|
||||
const seasonsR = ['2025', '2026', '2027 plan'].map(sn => ({
|
||||
key: sn, label: sn,
|
||||
style: { padding: '5px 13px', cursor: 'pointer', border: 'none', borderRadius: '999px', whiteSpace: 'nowrap', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.season === sn ? 'var(--color-neutral-100)' : 'transparent', color: st.season === sn ? 'var(--color-text)' : 'var(--p-ink-soft)', boxShadow: st.season === sn ? 'var(--shadow-sm)' : 'none' },
|
||||
onClick: () => this.setState({ season: sn }),
|
||||
}));
|
||||
const msgsR = st.msgs.map((m, i) => ({
|
||||
key: i, txt: m.txt,
|
||||
style: m.who === 'u'
|
||||
? { alignSelf: 'flex-end', maxWidth: '85%', background: 'var(--color-accent-200)', color: 'var(--color-accent-900)', borderRadius: '18px 18px 4px 18px', padding: '9px 13px', fontSize: '13px', lineHeight: 1.45 }
|
||||
: { alignSelf: 'flex-start', maxWidth: '90%', background: 'var(--color-bg)', border: '1px solid var(--color-divider)', borderRadius: '18px 18px 18px 4px', padding: '9px 13px', fontSize: '13px', lineHeight: 1.45 },
|
||||
}));
|
||||
const banner = st.season === '2025' ? '2025 is a past season — read-only.'
|
||||
: st.season === '2027 plan' ? 'Editing the 2027 plan — a separate copy. 2026 stays untouched.' : null;
|
||||
const modes = [
|
||||
['build', 'Build', 'M2 22v-5l5-5 5 5-5 5z', 'M9.5 14.5 16 8', 'm17 2 5 5-.5.5a3.53 3.53 0 0 1-5 0v0a3.53 3.53 0 0 1 0-5L17 2'],
|
||||
['plants', 'Plants', 'M7 20h10', 'M10 20c5.5-2.5.8-6.4 3-10', 'M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z'],
|
||||
['journal', 'Journal', 'M2 6h4', 'M2 10h4M2 14h4M2 18h4', 'M8 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z'],
|
||||
['chat', 'Assistant', 'M7.9 20A9 9 0 1 0 4 16.1L2 22Z', '', ''],
|
||||
];
|
||||
const modesR = modes.map(([id, label, d1, d2, d3]) => {
|
||||
const active = st.mode === id && !st.sel;
|
||||
return {
|
||||
key: id, label, d1, d2, d3,
|
||||
style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '3px', padding: '8px 4px', minHeight: '52px', cursor: 'pointer', border: 'none', borderRadius: '16px', fontFamily: 'var(--font-body)', background: active ? 'var(--color-accent-200)' : 'transparent', color: active ? 'var(--color-accent-800)' : 'var(--p-ink-soft)' },
|
||||
onClick: () => this.setState(s2 => ({ mode: (s2.mode === id && (id === 'journal' || id === 'chat')) ? 'build' : id, sel: null, armK: null, armP: id === 'plants' ? s2.armP : null })),
|
||||
};
|
||||
});
|
||||
const mPeekInsp = isMobile && !!insp;
|
||||
const mPeekJournal = isMobile && !insp && st.mode === 'journal';
|
||||
const mPeekChat = isMobile && !insp && st.mode === 'chat';
|
||||
const mPeek = mPeekInsp || mPeekJournal || mPeekChat;
|
||||
const mStripKinds = isMobile && !mPeek && st.mode === 'build';
|
||||
const mStripPlants = isMobile && !mPeek && st.mode === 'plants';
|
||||
return {
|
||||
...this.themeVals(),
|
||||
rootRef: this.rootRef, svgRef: this.svgRef, isMobile, isDesktop,
|
||||
gw: this.GW, gh: this.GH, gridMinor: showGrid ? this.gridMinor : '', gridMajor: showGrid ? this.gridMajor : '',
|
||||
borderW: 3 / s, hairW: 1 / s, labelsLayer: labels,
|
||||
svgStyle: { display: 'block', touchAction: 'none', cursor: st.armK || st.armP ? 'crosshair' : 'default' },
|
||||
viewStyle: { transform: `translate(${st.tx}px, ${st.ty}px) scale(${s})`, transformOrigin: '0 0', transition: st.anim ? 'transform .48s cubic-bezier(.22,.85,.3,1)' : 'none' },
|
||||
objectsR, plopsR, selO, ghostR, kindsR, plantsR, insp, summary,
|
||||
onCanvasDown: this.onCanvasDown, onCanvasMove: this.onCanvasMove, onCanvasUp: this.onCanvasUp,
|
||||
onDragOver: this.onDragOver, onDrop: this.onDrop,
|
||||
notFocus: !st.focus, focusName: focused ? focused.name : null, onBack: this.back,
|
||||
q: st.q, onQ: (e) => this.setState({ q: e.target.value }),
|
||||
zoomIn: this.zoomIn, zoomOut: this.zoomOut, zoomFit: this.zoomFit,
|
||||
onUndo: this.onUndo, undoDisabled: !st.undo.length,
|
||||
tabsR, seasonsR, banner,
|
||||
tabPlot: st.tab === 'plot', tabJournal: st.tab === 'journal', tabHistory: st.tab === 'history', tabChat: st.tab === 'chat',
|
||||
entriesR: st.entries.map((e, i) => ({ key: i, ...e })),
|
||||
jr: st.jr, onJr: (e) => this.setState({ jr: e.target.value }), onAddJr: this.addJournal,
|
||||
onJrKey: (e) => { if (e.key === 'Enter') this.addJournal(); },
|
||||
jrPlaceholder: focused ? 'Note something about ' + focused.name + '…' : 'Note something…',
|
||||
historyR: [...st.undo].reverse().map((h, i) => ({ key: i, label: h.label, when: h.when })),
|
||||
historyEmpty: !st.undo.length,
|
||||
msgsR, chat: st.chat, onChat: (e) => this.setState({ chat: e.target.value }), onSend: this.sendChat,
|
||||
onChatKey: (e) => { if (e.key === 'Enter') this.sendChat(); },
|
||||
mTitle: focused ? focused.name : 'Home Garden',
|
||||
seasonShort: st.season === '2027 plan' ? '27 plan' : st.season.slice(2),
|
||||
onCycleSeason: () => { const order = ['2025', '2026', '2027 plan']; this.setState(s2 => ({ season: order[(order.indexOf(s2.season) + 1) % 3] })); },
|
||||
mPeek, mPeekInsp, mPeekJournal, mPeekChat,
|
||||
mPeekTitle: mPeekInsp ? (insp.isObj ? 'Selected' : 'Planting') : mPeekJournal ? 'Journal' : 'Assistant',
|
||||
onPeekClose: () => this.setState(s2 => ({ sel: null, mode: (s2.mode === 'journal' || s2.mode === 'chat') ? 'build' : s2.mode })),
|
||||
mStrip: mStripKinds || mStripPlants, mStripKinds, mStripPlants, mStripDone: mStripPlants && !!st.focus,
|
||||
modesR,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,236 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||
<script src="pansy-theme.js"></script>
|
||||
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||
<style>
|
||||
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||
html,body{margin:0;min-height:100%}
|
||||
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||
</style>
|
||||
</helmet>
|
||||
<div data-screen-label="Gardens" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text)">
|
||||
<nav class="nav">
|
||||
<span class="nav-brand" style="display:flex;align-items:center;gap:8px">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||
pansy
|
||||
</span>
|
||||
<a href="Pansy Gardens.dc.html" aria-current="page">Gardens</a>
|
||||
<a href="Pansy Plants.dc.html">Plants</a>
|
||||
<span style="margin-left:auto;display:flex;align-items:center;gap:10px">
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||
</button>
|
||||
<a href="Pansy Settings.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px" title="Settings">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z"></path></svg>
|
||||
</a>
|
||||
<a href="Pansy Login.dc.html" style="width:32px;height:32px;border-radius:999px;background:var(--color-accent-2-300);color:var(--color-accent-2-800);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;text-decoration:none">S</a>
|
||||
</span>
|
||||
</nav>
|
||||
<div style="max-width:1080px;margin:0 auto;padding:28px 24px 56px">
|
||||
<div style="display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:20px">
|
||||
<h2 style="margin:0">Gardens</h2>
|
||||
<span style="font-size:14px;color:var(--p-ink-mute);font-weight:600">Every plot you tend — and the ones you're scheming</span>
|
||||
<button class="btn btn-primary" style="border-radius:999px;margin-left:auto" onClick="{{ onNew }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
|
||||
New garden
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:18px">
|
||||
<sc-for list="{{ cards }}" as="g" hint-placeholder-count="3">
|
||||
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);overflow:hidden;display:flex;flex-direction:column" style-hover="box-shadow:var(--shadow-md)">
|
||||
<a href="Pansy Editor.dc.html" style="display:block;background:var(--p-field);border-bottom:1px solid var(--color-divider);text-decoration:none">
|
||||
<svg viewBox="{{ g.vb }}" style="display:block;width:100%;height:150px">
|
||||
<sc-for list="{{ g.thumb }}" as="t" hint-placeholder-count="4">
|
||||
<sc-if value="{{ t.isRect }}" hint-placeholder-val="{{ true }}"><rect x="{{ t.x }}" y="{{ t.y }}" width="{{ t.w }}" height="{{ t.h }}" rx="{{ t.rr }}" fill="{{ t.fill }}" stroke="{{ t.stroke }}" stroke-width="{{ t.sw }}" stroke-dasharray="{{ t.dash }}"></rect></sc-if>
|
||||
<sc-if value="{{ t.isCircle }}" hint-placeholder-val="{{ false }}"><circle cx="{{ t.cx }}" cy="{{ t.cy }}" r="{{ t.r }}" fill="{{ t.fill }}" stroke="{{ t.stroke }}" stroke-width="{{ t.sw }}"></circle></sc-if>
|
||||
</sc-for>
|
||||
</svg>
|
||||
</a>
|
||||
<div style="padding:16px 18px;display:flex;flex-direction:column;gap:8px;flex:1">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-family:var(--font-heading);font-size:18px">{{ g.name }}</span>
|
||||
<sc-if value="{{ g.planTag }}" hint-placeholder-val="{{ false }}"><span class="tag tag-accent" style="border-radius:999px">{{ g.planTag }}</span></sc-if>
|
||||
<span style="font-size:12.5px;color:var(--p-ink-mute);font-weight:600;margin-left:auto">{{ g.sizeText }}</span>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--p-ink-soft);line-height:1.5">{{ g.meta }}</div>
|
||||
<sc-if value="{{ g.sharedText }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="font-size:12px;color:var(--color-accent-2-700);font-weight:600">{{ g.sharedText }}</div>
|
||||
</sc-if>
|
||||
<div style="display:flex;gap:8px;margin-top:auto;padding-top:8px">
|
||||
<a href="Pansy Editor.dc.html" class="btn btn-primary" style="border-radius:999px;flex:1;text-decoration:none">Open</a>
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px" title="Share" onClick="{{ g.onShare }}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><path d="m8.6 10.6 6.8-3.9"></path><path d="m8.6 13.4 6.8 3.9"></path></svg>
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px" title="Copy — plan a season from it" onClick="{{ g.onCopy }}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px" title="Delete" onClick="{{ g.onDelete }}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-700)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ dlgNew }}" hint-placeholder-val="{{ false }}">
|
||||
<div class="dialog-backdrop" style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
|
||||
<div class="dialog" style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(420px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
|
||||
<h3 style="margin:0;font-size:22px">A new garden</h3>
|
||||
<div class="field"><label>Name</label><input class="input" style="border-radius:999px" value="{{ nName }}" onChange="{{ onNName }}" placeholder="Back forty"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Width (ft)</label><input class="input" style="border-radius:999px" type="number" value="{{ nW }}" onChange="{{ onNW }}"></div>
|
||||
<div class="field" style="flex:1"><label>Depth (ft)</label><input class="input" style="border-radius:999px" type="number" value="{{ nH }}" onChange="{{ onNH }}"></div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--p-ink-mute)">Stored in centimeters under the hood — feet are just how you talk.</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Never mind</button>
|
||||
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onCreate }}">Break ground</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ dlgShare }}" hint-placeholder-val="{{ false }}">
|
||||
<div class="dialog-backdrop" style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
|
||||
<div class="dialog" style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(440px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
|
||||
<h3 style="margin:0;font-size:22px">Share {{ shareName }}</h3>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input class="input" style="border-radius:999px" placeholder="[email protected]" value="{{ shEmail }}" onChange="{{ onShEmail }}">
|
||||
<button class="btn btn-primary" style="border-radius:999px;flex:none" onClick="{{ onInvite }}">Invite</button>
|
||||
</div>
|
||||
<sc-for list="{{ shares }}" as="sh" hint-placeholder-count="1">
|
||||
<div style="display:flex;align-items:center;gap:10px;background:var(--color-bg);border:1px solid var(--color-divider);border-radius:999px;padding:8px 8px 8px 16px">
|
||||
<span style="font-size:13px;font-weight:600">{{ sh.email }}</span>
|
||||
<button onClick="{{ sh.onRole }}" class="tag tag-accent-2" style="border-radius:999px;border:none;cursor:pointer;margin-left:auto;font-family:var(--font-body)" title="Click to switch role">{{ sh.role }}</button>
|
||||
</div>
|
||||
</sc-for>
|
||||
<div class="hr" style="margin:2px 0"></div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="font-size:13px;font-weight:600">Read-only link</span>
|
||||
<button onClick="{{ onToggleLink }}" style="{{ linkToggleStyle }}">{{ linkToggleLabel }}</button>
|
||||
</div>
|
||||
<sc-if value="{{ linkOn }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="font-size:12px;color:var(--p-ink-soft);background:var(--color-bg);border:1px dashed var(--color-divider);border-radius:999px;padding:8px 14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">pansy.example.com/p/gd_7Kx2mQ…</div>
|
||||
</sc-if>
|
||||
<div style="display:flex;justify-content:flex-end">
|
||||
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const bedS = { fill: 'var(--p-bed-fill)', stroke: 'var(--p-bed-stroke)' };
|
||||
const th = (isRect, x, y, w, h, sty, dash) => isRect
|
||||
? { isRect: true, x: x - w / 2, y: y - h / 2, w, h, rr: Math.min(14, w * 0.14), fill: sty.fill, stroke: sty.stroke, sw: 5, dash }
|
||||
: { isCircle: true, cx: x, cy: y, r: w / 2, fill: sty.fill, stroke: sty.stroke, sw: 5 };
|
||||
const dot = (x, y, r, c) => ({ isCircle: true, cx: x, cy: y, r, fill: c, stroke: 'none', sw: 0 });
|
||||
const homeThumb = [
|
||||
{ isRect: true, x: 8, y: 8, w: 1203, h: 837, rr: 18, fill: 'var(--p-field)', stroke: 'var(--p-tree-stroke)', sw: 8 },
|
||||
th(true, 560, 285, 1010, 75, { fill: 'var(--p-path-fill)', stroke: 'var(--p-path-stroke)' }, '6 10'),
|
||||
th(true, 110, 140, 91, 183, bedS), th(true, 320, 140, 91, 183, bedS), th(true, 530, 140, 91, 183, bedS), th(true, 740, 140, 91, 183, bedS), th(true, 950, 140, 91, 183, bedS),
|
||||
th(true, 190, 392, 244, 61, bedS), th(true, 190, 502, 244, 61, bedS), th(true, 190, 612, 244, 61, bedS), th(true, 190, 722, 244, 61, bedS),
|
||||
th(false, 1090, 110, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }), th(false, 1090, 165, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }), th(false, 1090, 220, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }),
|
||||
dot(110, 90, 26, '#c8553d'), dot(110, 150, 26, '#c8553d'), dot(110, 200, 26, '#c8553d'),
|
||||
dot(300, 110, 32, '#6f8f4f'), dot(340, 180, 32, '#6f8f4f'),
|
||||
dot(510, 105, 38, '#46683c'), dot(550, 180, 38, '#46683c'),
|
||||
dot(740, 140, 38, '#5f8f45'),
|
||||
{ isRect: true, x: 80, y: 374, w: 220, h: 36, rr: 18, fill: '#97a97c', stroke: 'none', sw: 0 },
|
||||
{ isRect: true, x: 80, y: 484, w: 220, h: 36, rr: 18, fill: '#b2622d', stroke: 'none', sw: 0 },
|
||||
{ isRect: true, x: 80, y: 594, w: 220, h: 36, rr: 18, fill: '#d9912f', stroke: 'none', sw: 0 },
|
||||
dot(1090, 110, 13, '#c2913a'), dot(1090, 165, 13, '#c2913a'), dot(1090, 220, 13, '#c2913a'),
|
||||
];
|
||||
const balconyThumb = [
|
||||
{ isRect: true, x: 4, y: 4, w: 358, h: 175, rr: 12, fill: 'var(--p-field)', stroke: 'var(--p-tree-stroke)', sw: 4 },
|
||||
th(false, 70, 90, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }), th(false, 130, 90, 40, 0, { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' }),
|
||||
th(false, 195, 90, 55, 0, { fill: 'var(--p-bkt-fill)', stroke: 'var(--p-bkt-stroke)' }),
|
||||
th(true, 295, 92, 100, 50, bedS),
|
||||
dot(70, 90, 12, '#c8553d'), dot(130, 90, 12, '#5f8f45'), dot(195, 90, 14, '#b2622d'),
|
||||
];
|
||||
this.nid = 10;
|
||||
this.state = {
|
||||
cards: [
|
||||
{ id: 1, name: 'Home Garden', vb: '0 0 1219 853', thumb: homeThumb, sizeText: '40\u2032 \u00d7 28\u2032', meta: '9 beds \u00b7 3 bags \u00b7 2 buckets \u00b7 136 plantings \u00b7 tended since 2024', sharedText: 'Shared with lauren@ \u00b7 editor', planTag: null },
|
||||
{ id: 2, name: 'Home Garden \u2014 2027', vb: '0 0 1219 853', thumb: homeThumb, sizeText: '40\u2032 \u00d7 28\u2032', meta: 'A copy to scheme next season in \u2014 rearrange freely, 2026 stays put.', sharedText: null, planTag: 'plan' },
|
||||
{ id: 3, name: 'Balcony', vb: '0 0 366 183', thumb: balconyThumb, sizeText: '12\u2032 \u00d7 6\u2032', meta: '2 bags \u00b7 1 bucket \u00b7 1 rail bed \u00b7 the overflow department', sharedText: null, planTag: null },
|
||||
],
|
||||
dlg: null, nName: '', nW: 20, nH: 12, shEmail: '', shareId: null, linkOn: true,
|
||||
shares: [{ email: '[email protected]', role: 'editor' }],
|
||||
};
|
||||
}
|
||||
componentDidMount() { this.themeMount(); }
|
||||
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
|
||||
themeMount() {
|
||||
const PT = window.PansyTheme; if (!PT) return;
|
||||
this.setState({ themePref: PT.get() });
|
||||
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||
}
|
||||
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||
themeVals() {
|
||||
const p = this.state.themePref || 'system';
|
||||
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||
}
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const stop = (e) => e.stopPropagation();
|
||||
return {
|
||||
...this.themeVals(),
|
||||
cards: st.cards.map(g => ({
|
||||
...g, key: g.id,
|
||||
thumb: g.thumb.map((t, i) => ({ key: i, ...t })),
|
||||
onShare: () => this.setState({ dlg: 'share', shareId: g.id }),
|
||||
onCopy: () => this.setState(s2 => {
|
||||
const n = { ...g, id: this.nid++, name: g.name.replace(/ — \d{4}$/, '') + ' — copy', planTag: 'plan', sharedText: null, meta: 'A fresh copy — objects and active plantings came along; shares didn\u2019t.' };
|
||||
return { cards: [...s2.cards, n] };
|
||||
}),
|
||||
onDelete: () => this.setState(s2 => ({ cards: s2.cards.filter(c => c.id !== g.id) })),
|
||||
})),
|
||||
dlgNew: st.dlg === 'new', dlgShare: st.dlg === 'share',
|
||||
shareName: (st.cards.find(c => c.id === st.shareId) || {}).name || '',
|
||||
onNew: () => this.setState({ dlg: 'new', nName: '', nW: 20, nH: 12 }),
|
||||
onCloseDlg: () => this.setState({ dlg: null }),
|
||||
stop,
|
||||
nName: st.nName, onNName: (e) => this.setState({ nName: e.target.value }),
|
||||
nW: st.nW, onNW: (e) => this.setState({ nW: e.target.value }),
|
||||
nH: st.nH, onNH: (e) => this.setState({ nH: e.target.value }),
|
||||
onCreate: () => this.setState(s2 => ({
|
||||
dlg: null,
|
||||
cards: [...s2.cards, {
|
||||
id: this.nid++, name: s2.nName.trim() || 'New garden', vb: '0 0 366 183',
|
||||
thumb: [{ key: 0, isRect: true, x: 4, y: 4, w: 358, h: 175, rr: 12, fill: 'var(--p-field)', stroke: 'var(--p-tree-stroke)', sw: 4 }],
|
||||
sizeText: s2.nW + '\u2032 \u00d7 ' + s2.nH + '\u2032', meta: 'Bare ground \u2014 drag your first bed on.', sharedText: null, planTag: null,
|
||||
}],
|
||||
})),
|
||||
shEmail: st.shEmail, onShEmail: (e) => this.setState({ shEmail: e.target.value }),
|
||||
onInvite: () => { const em = st.shEmail.trim(); if (!em) return; this.setState(s2 => ({ shares: [...s2.shares, { email: em, role: 'viewer' }], shEmail: '' })); },
|
||||
shares: st.shares.map((sh, i) => ({
|
||||
key: i, email: sh.email, role: sh.role,
|
||||
onRole: () => this.setState(s2 => ({ shares: s2.shares.map((x, j) => j === i ? { ...x, role: x.role === 'viewer' ? 'editor' : 'viewer' } : x) })),
|
||||
})),
|
||||
linkOn: st.linkOn,
|
||||
onToggleLink: () => this.setState(s2 => ({ linkOn: !s2.linkOn })),
|
||||
linkToggleLabel: st.linkOn ? 'On' : 'Off',
|
||||
linkToggleStyle: { marginLeft: 'auto', cursor: 'pointer', border: 'none', borderRadius: '999px', padding: '5px 16px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.linkOn ? 'var(--color-accent-2-300)' : 'var(--color-neutral-300)', color: st.linkOn ? 'var(--color-accent-2-800)' : 'var(--p-ink-soft)' },
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,69 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||
<script src="pansy-theme.js"></script>
|
||||
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||
<style>
|
||||
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||
html,body{margin:0;min-height:100%}
|
||||
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||
</style>
|
||||
</helmet>
|
||||
<div data-screen-label="Login" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text);display:flex;align-items:center;justify-content:center;padding:24px;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;width:520px;height:520px;border-radius:999px;background:var(--color-accent-2-200);top:-180px;right:-140px;opacity:0.55"></div>
|
||||
<div style="position:absolute;width:340px;height:340px;border-radius:999px;background:var(--color-accent-200);bottom:-140px;left:-100px;opacity:0.5"></div>
|
||||
<button class="btn btn-icon btn-secondary" style="position:absolute;top:18px;right:18px;border-radius:999px;background:var(--color-neutral-100)" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||
</button>
|
||||
<div style="width:min(400px,100%);display:flex;flex-direction:column;gap:22px;position:relative">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center">
|
||||
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||
<h1 style="margin:0;font-size:40px">pansy</h1>
|
||||
<p style="margin:0;font-size:14.5px;color:var(--p-ink-soft)">Plan the plot. Keep the notes. Grow the thing.</p>
|
||||
</div>
|
||||
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);box-shadow:var(--shadow-md);padding:26px;display:flex;flex-direction:column;gap:14px">
|
||||
<div class="field"><label>Email</label><input class="input" style="border-radius:999px;font-size:16px" type="email" placeholder="[email protected]"></div>
|
||||
<div class="field"><label>Password</label><input class="input" style="border-radius:999px;font-size:16px" type="password" placeholder="••••••••••"></div>
|
||||
<a href="Pansy Gardens.dc.html" class="btn btn-primary btn-block" style="border-radius:999px;text-decoration:none;margin-top:2px">Into the garden</a>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:2px 0">
|
||||
<span style="flex:1;height:1px;background:var(--color-divider)"></span>
|
||||
<span style="font-size:12px;color:var(--p-ink-mute)">or</span>
|
||||
<span style="flex:1;height:1px;background:var(--color-divider)"></span>
|
||||
</div>
|
||||
<a href="Pansy Gardens.dc.html" class="btn btn-secondary btn-block" style="border-radius:999px;text-decoration:none;gap:8px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
Sign in with Authentik
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin:0;text-align:center;font-size:13px;color:var(--p-ink-soft)">New here? <a href="Pansy Gardens.dc.html" style="font-weight:700">Create an account</a> — the first one becomes admin.</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
state = { themePref: 'system' };
|
||||
componentDidMount() {
|
||||
const PT = window.PansyTheme; if (!PT) return;
|
||||
this.setState({ themePref: PT.get() });
|
||||
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||
}
|
||||
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
|
||||
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||
renderVals() {
|
||||
const p = this.state.themePref || 'system';
|
||||
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,33 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||
<script src="pansy-theme.js"></script>
|
||||
<style>
|
||||
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||
html,body{margin:0;min-height:100%}
|
||||
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||
</style>
|
||||
</helmet>
|
||||
<div data-screen-label="Phone preview" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:28px">
|
||||
<div style="display:flex;align-items:baseline;gap:12px">
|
||||
<h3 style="margin:0">pansy in your pocket</h3>
|
||||
<span style="font-size:13px;color:var(--p-ink-mute);font-weight:600">same editor, one column — pinch to zoom, tap a bed, plant from the strip</span>
|
||||
</div>
|
||||
<x-import component-from-global-scope="IOSDevice" from="./ios-frame.jsx" hint-size="430px,880px">
|
||||
<div style="height:100%;padding-top:60px;box-sizing:border-box">
|
||||
<dc-import name="Pansy Editor" layout="phone" hint-size="100%,100%" style="width:100%;height:100%"></dc-import>
|
||||
</div>
|
||||
</x-import>
|
||||
</div>
|
||||
</x-dc>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,254 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||
<script src="pansy-theme.js"></script>
|
||||
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||
<style>
|
||||
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||
html,body{margin:0;min-height:100%}
|
||||
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||
</style>
|
||||
</helmet>
|
||||
<div data-screen-label="Plants" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text)">
|
||||
<nav class="nav">
|
||||
<span class="nav-brand" style="display:flex;align-items:center;gap:8px">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||
pansy
|
||||
</span>
|
||||
<a href="Pansy Gardens.dc.html">Gardens</a>
|
||||
<a href="Pansy Plants.dc.html" aria-current="page">Plants</a>
|
||||
<span style="margin-left:auto;display:flex;align-items:center;gap:10px">
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||
</button>
|
||||
<a href="Pansy Settings.dc.html" class="btn btn-icon btn-secondary" style="border-radius:999px" title="Settings">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z"></path></svg>
|
||||
</a>
|
||||
<a href="Pansy Login.dc.html" style="width:32px;height:32px;border-radius:999px;background:var(--color-accent-2-300);color:var(--color-accent-2-800);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;text-decoration:none">S</a>
|
||||
</span>
|
||||
</nav>
|
||||
<div style="max-width:1080px;margin:0 auto;padding:28px 24px 56px">
|
||||
<div style="display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:18px">
|
||||
<h2 style="margin:0">Plants</h2>
|
||||
<span style="font-size:14px;color:var(--p-ink-mute);font-weight:600">The built-ins plus everything you've added</span>
|
||||
<span style="margin-left:auto;display:flex;gap:8px">
|
||||
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onScan }}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path><circle cx="12" cy="13" r="3"></circle></svg>
|
||||
Scan a packet
|
||||
</button>
|
||||
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onAdd }}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>
|
||||
Add a plant
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:20px">
|
||||
<input class="input" style="border-radius:999px;max-width:260px" placeholder="Find a plant…" value="{{ q }}" onChange="{{ onQ }}">
|
||||
<sc-for list="{{ cats }}" as="c">
|
||||
<button onClick="{{ c.onClick }}" style="{{ c.style }}">{{ c.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px">
|
||||
<sc-for list="{{ cardsR }}" as="p" hint-placeholder-count="8">
|
||||
<div onClick="{{ p.onClick }}" style="{{ p.cardStyle }}" style-hover="box-shadow:var(--shadow-md)">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<svg width="40" height="40" style="flex:none"><circle cx="20" cy="20" r="20" fill="{{ p.color }}"></circle></svg>
|
||||
<span style="position:absolute;width:40px;text-align:center;color:#fffaf1;font-family:var(--font-heading);font-size:17px;pointer-events:none">{{ p.letter }}</span>
|
||||
<span style="display:flex;flex-direction:column;gap:1px;min-width:0">
|
||||
<span style="font-family:var(--font-heading);font-size:16.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ p.name }}</span>
|
||||
<span style="font-size:12px;color:var(--p-ink-mute);font-weight:600">{{ p.sub }}</span>
|
||||
</span>
|
||||
<sc-if value="{{ p.builtin }}" hint-placeholder-val="{{ false }}"><span class="tag tag-neutral" style="border-radius:999px;margin-left:auto;flex:none">built-in</span></sc-if>
|
||||
</div>
|
||||
<div style="font-size:12.5px;color:var(--p-ink-soft);margin-top:10px">{{ p.lotText }}</div>
|
||||
<sc-if value="{{ p.open }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px">
|
||||
<sc-for list="{{ p.lots }}" as="l" hint-placeholder-count="1">
|
||||
<div style="background:var(--color-bg);border:1px solid var(--color-divider);border-radius:var(--radius-md);padding:10px 13px">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<span style="font-size:12.5px;font-weight:700">{{ l.vendor }}</span>
|
||||
<span style="font-size:11.5px;color:var(--p-ink-mute);margin-left:auto">packed for {{ l.year }}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--p-ink-soft);margin-top:3px">{{ l.detail }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
<sc-if value="{{ p.noLots }}"><div style="font-size:12.5px;color:var(--p-ink-mute)">No seed lots yet — scan a packet to add one.</div></sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ dlgScan }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
|
||||
<div style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(560px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
|
||||
<h3 style="margin:0;font-size:22px">Scan a seed packet</h3>
|
||||
<sc-if value="{{ scanStep0 }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="border:2px dashed var(--color-neutral-400);border-radius:var(--radius-lg);padding:36px 20px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center">
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path><circle cx="12" cy="13" r="3"></circle></svg>
|
||||
<div style="font-size:14px;font-weight:600">Photograph the packet — front is enough</div>
|
||||
<div style="font-size:12.5px;color:var(--p-ink-mute);max-width:36ch">The vision model reads it into fields. It only reads; nothing is saved until you confirm.</div>
|
||||
<button class="btn btn-primary" style="border-radius:999px;margin-top:4px" onClick="{{ onScanGo }}">Use a sample photo</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ scanStep1 }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<div class="field"><label>Variety</label><input class="input" style="border-radius:999px" value="Garlic — Music"></div>
|
||||
<div class="field"><label>Vendor</label><input class="input" style="border-radius:999px" value="Keene Organics"></div>
|
||||
<div class="field"><label>Packed for</label><input class="input" style="border-radius:999px" value="2026"></div>
|
||||
<div class="field"><label>Quantity</label><input class="input" style="border-radius:999px" value="50 cloves"></div>
|
||||
</div>
|
||||
<div style="font-size:12.5px;font-weight:700;color:var(--p-ink-soft);margin-top:2px">Match it to your catalog — nothing is auto-created:</div>
|
||||
<sc-for list="{{ matches }}" as="m">
|
||||
<button onClick="{{ m.onClick }}" style="{{ m.style }}">
|
||||
<span style="font-size:13.5px;font-weight:700">{{ m.label }}</span>
|
||||
<span style="font-size:12px;color:var(--p-ink-mute);margin-left:auto">{{ m.sub }}</span>
|
||||
</button>
|
||||
</sc-for>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px">
|
||||
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Cancel</button>
|
||||
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onConfirmScan }}">Add the lot</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ dlgAdd }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="position:fixed;inset:0;background:color-mix(in srgb, #201e1d 40%, transparent);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50" onClick="{{ onCloseDlg }}">
|
||||
<div style="background:var(--color-neutral-100);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:26px;width:min(420px,100%);display:flex;flex-direction:column;gap:14px" onClick="{{ stop }}">
|
||||
<h3 style="margin:0;font-size:22px">A new plant</h3>
|
||||
<div class="field"><label>Name</label><input class="input" style="border-radius:999px" value="{{ aName }}" onChange="{{ onAName }}" placeholder="Delicata squash"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Category</label>
|
||||
<select class="input" style="border-radius:999px" value="{{ aCat }}" onChange="{{ onACat }}">
|
||||
<option value="vegetable">Vegetable</option><option value="herb">Herb</option><option value="flower">Flower</option><option value="fruit">Fruit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:1"><label>Spacing (in)</label><input class="input" style="border-radius:999px" type="number" value="{{ aSp }}" onChange="{{ onASp }}"></div>
|
||||
</div>
|
||||
<div class="field"><label>Marker color</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<sc-for list="{{ swatches }}" as="sw">
|
||||
<button onClick="{{ sw.onClick }}" style="{{ sw.style }}" title="{{ sw.hex }}"></button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn btn-secondary" style="border-radius:999px" onClick="{{ onCloseDlg }}">Never mind</button>
|
||||
<button class="btn btn-primary" style="border-radius:999px" onClick="{{ onCreatePlant }}">Add it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
q: '', cat: 'all', openId: null, dlg: null, scanStep: 0, matchSel: 1,
|
||||
aName: '', aCat: 'vegetable', aSp: 12, aColor: '#97a97c',
|
||||
plants: [
|
||||
{ id: 1, name: 'Garlic', letter: 'G', color: '#97a97c', cat: 'vegetable', spacing: '6″', days: 240, builtin: true, lots: [{ vendor: 'Johnny\u2019s \u2014 Music', year: 2025, detail: '50 cloves \u00b7 ~14 left after this season\u2019s beds' }] },
|
||||
{ id: 2, name: 'Tomato — Cherokee Purple', letter: 'T', color: '#c8553d', cat: 'vegetable', spacing: '24″', days: 80, builtin: false, lots: [{ vendor: 'Baker Creek', year: 2026, detail: '25 seeds \u00b7 95% germination \u00b7 ~22 left' }] },
|
||||
{ id: 3, name: 'Cucumber — Marketmore', letter: 'C', color: '#6f8f4f', cat: 'vegetable', spacing: '12″', days: 65, builtin: false, lots: [] },
|
||||
{ id: 4, name: 'Watermelon — Sugar Baby', letter: 'W', color: '#46683c', cat: 'fruit', spacing: '36″', days: 78, builtin: false, lots: [{ vendor: 'Ferry-Morse', year: 2025, detail: '20 seeds \u00b7 ~16 left' }] },
|
||||
{ id: 5, name: 'Basil — Genovese', letter: 'B', color: '#5f8f45', cat: 'herb', spacing: '10″', days: 60, builtin: true, lots: [] },
|
||||
{ id: 6, name: 'Pepper — Jalapeño', letter: 'P', color: '#b2622d', cat: 'vegetable', spacing: '18″', days: 75, builtin: false, lots: [] },
|
||||
{ id: 7, name: 'Marigold', letter: 'Ma', color: '#d9912f', cat: 'flower', spacing: '8″', days: 50, builtin: true, lots: [] },
|
||||
{ id: 8, name: 'Melon — Hale\u2019s Best', letter: 'Me', color: '#c2913a', cat: 'fruit', spacing: '36″', days: 85, builtin: false, lots: [] },
|
||||
{ id: 9, name: 'Carrot — Danvers', letter: 'Cr', color: '#d07a2e', cat: 'vegetable', spacing: '3″', days: 70, builtin: true, lots: [] },
|
||||
{ id: 10, name: 'Bush bean — Provider', letter: 'Bn', color: '#7c9a55', cat: 'vegetable', spacing: '6″', days: 55, builtin: true, lots: [] },
|
||||
{ id: 11, name: 'Zinnia', letter: 'Z', color: '#c65a4e', cat: 'flower', spacing: '10″', days: 60, builtin: true, lots: [] },
|
||||
{ id: 12, name: 'Thyme', letter: 'Th', color: '#6d7f5a', cat: 'herb', spacing: '8″', days: 90, builtin: true, lots: [] },
|
||||
],
|
||||
};
|
||||
this.nid = 100;
|
||||
}
|
||||
componentDidMount() { this.themeMount(); }
|
||||
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
|
||||
themeMount() {
|
||||
const PT = window.PansyTheme; if (!PT) return;
|
||||
this.setState({ themePref: PT.get() });
|
||||
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||
}
|
||||
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||
themeVals() {
|
||||
const p = this.state.themePref || 'system';
|
||||
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||
}
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const ql = st.q.trim().toLowerCase();
|
||||
const catList = [['all', 'All'], ['vegetable', 'Vegetables'], ['herb', 'Herbs'], ['flower', 'Flowers'], ['fruit', 'Fruit']];
|
||||
const shown = st.plants.filter(p => (st.cat === 'all' || p.cat === st.cat) && (!ql || p.name.toLowerCase().includes(ql)));
|
||||
return {
|
||||
...this.themeVals(),
|
||||
q: st.q, onQ: (e) => this.setState({ q: e.target.value }),
|
||||
cats: catList.map(([id, label]) => ({
|
||||
key: id, label,
|
||||
style: { cursor: 'pointer', border: '1px solid ' + (st.cat === id ? 'var(--color-accent-400)' : 'var(--color-divider)'), borderRadius: '999px', padding: '7px 16px', fontFamily: 'var(--font-body)', fontSize: '13px', fontWeight: 700, background: st.cat === id ? 'var(--color-accent-200)' : 'transparent', color: st.cat === id ? 'var(--color-accent-800)' : 'var(--p-ink-soft)' },
|
||||
onClick: () => this.setState({ cat: id }),
|
||||
})),
|
||||
cardsR: shown.map(p => ({
|
||||
key: p.id, name: p.name, letter: p.letter, color: p.color, builtin: p.builtin,
|
||||
sub: p.cat[0].toUpperCase() + p.cat.slice(1) + ' · ' + p.spacing + ' spacing · ' + p.days + ' days',
|
||||
lotText: p.lots.length ? p.lots.length + (p.lots.length === 1 ? ' seed lot' : ' seed lots') + ' · click to see' : 'No seed lots · click to expand',
|
||||
open: st.openId === p.id,
|
||||
lots: p.lots.map((l, i) => ({ key: i, ...l })),
|
||||
noLots: !p.lots.length,
|
||||
cardStyle: { background: 'var(--color-neutral-100)', border: '1px solid ' + (st.openId === p.id ? 'var(--color-accent-400)' : 'var(--color-divider)'), borderRadius: 'var(--radius-lg)', padding: '16px 18px', cursor: 'pointer', position: 'relative' },
|
||||
onClick: () => this.setState(s2 => ({ openId: s2.openId === p.id ? null : p.id })),
|
||||
})),
|
||||
dlgScan: st.dlg === 'scan', dlgAdd: st.dlg === 'add',
|
||||
scanStep0: st.scanStep === 0, scanStep1: st.scanStep === 1,
|
||||
onScan: () => this.setState({ dlg: 'scan', scanStep: 0, matchSel: 1 }),
|
||||
onAdd: () => this.setState({ dlg: 'add', aName: '', aCat: 'vegetable', aSp: 12, aColor: '#97a97c' }),
|
||||
onCloseDlg: () => this.setState({ dlg: null }),
|
||||
stop: (e) => e.stopPropagation(),
|
||||
onScanGo: () => this.setState({ scanStep: 1 }),
|
||||
matches: [
|
||||
{ label: 'Garlic (built-in)', sub: 'existing plant \u2014 lot attaches to it' },
|
||||
{ label: 'Garlic \u2014 Music (yours)', sub: 'best match \u00b7 spacing 6\u2033' },
|
||||
{ label: 'Create a new plant', sub: 'from the extracted fields' },
|
||||
].map((m, i) => ({
|
||||
key: i, ...m,
|
||||
style: { display: 'flex', alignItems: 'center', gap: '8px', textAlign: 'left', cursor: 'pointer', borderRadius: '999px', padding: '10px 16px', fontFamily: 'var(--font-body)', background: st.matchSel === i ? 'var(--color-accent-200)' : 'var(--color-bg)', border: st.matchSel === i ? '1px solid var(--color-accent-400)' : '1px solid var(--color-divider)' },
|
||||
onClick: () => this.setState({ matchSel: i }),
|
||||
})),
|
||||
onConfirmScan: () => this.setState(s2 => ({
|
||||
dlg: null,
|
||||
plants: s2.plants.map(p => p.id === 1 ? { ...p, lots: [...p.lots, { vendor: 'Keene Organics \u2014 Music', year: 2026, detail: '50 cloves \u00b7 untouched' }] } : p),
|
||||
openId: 1,
|
||||
})),
|
||||
aName: st.aName, onAName: (e) => this.setState({ aName: e.target.value }),
|
||||
aCat: st.aCat, onACat: (e) => this.setState({ aCat: e.target.value }),
|
||||
aSp: st.aSp, onASp: (e) => this.setState({ aSp: e.target.value }),
|
||||
swatches: ['#97a97c', '#c8553d', '#5f8f45', '#d9912f', '#b2622d', '#6d7f5a'].map(hex => ({
|
||||
key: hex, hex,
|
||||
style: { width: '30px', height: '30px', borderRadius: '999px', cursor: 'pointer', background: hex, border: st.aColor === hex ? '3px solid var(--color-accent)' : '3px solid transparent' },
|
||||
onClick: () => this.setState({ aColor: hex }),
|
||||
})),
|
||||
onCreatePlant: () => this.setState(s2 => ({
|
||||
dlg: null,
|
||||
plants: [...s2.plants, { id: this.nid++, name: s2.aName.trim() || 'New plant', letter: (s2.aName.trim()[0] || 'N').toUpperCase(), color: s2.aColor, cat: s2.aCat, spacing: s2.aSp + '″', days: 70, builtin: false, lots: [] }],
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,158 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/styles.css">
|
||||
<script src="pansy-theme.js"></script>
|
||||
<script src="_ds/organic-9b68eb3f-20a3-40f4-b132-a5a6542b5b19/_ds_bundle.js"></script>
|
||||
<style>
|
||||
:root{--p-field:#efe3c9;--p-grid-ink:#201e1d;--p-ink-strong:#474238;--p-ink-soft:#645c50;--p-ink-mute:#82796a;--p-bed-fill:#e3d0ac;--p-bed-stroke:#b5a37f;--p-ing-fill:#d6bf98;--p-ing-stroke:#b1a07c;--p-path-fill:#ece1cb;--p-path-stroke:#cabfa6;--p-bag-fill:#d8c5a2;--p-bag-stroke:#a99677;--p-bkt-fill:#cfc3ad;--p-bkt-stroke:#9a8d76;--p-tree-fill:#dce9c6;--p-tree-stroke:#8fa073;--p-str-fill:#d9cfbc;--p-str-stroke:#a3947c}
|
||||
html,body{margin:0;min-height:100%}
|
||||
a{color:var(--color-accent)} a:hover{color:var(--color-accent-600)}
|
||||
</style>
|
||||
</helmet>
|
||||
<div data-screen-label="Settings" style="min-height:100vh;background:var(--color-bg);font-family:var(--font-body);color:var(--color-text)">
|
||||
<nav class="nav">
|
||||
<span class="nav-brand" style="display:flex;align-items:center;gap:8px">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-2-600)" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7 20h10"></path><path d="M10 20c5.5-2.5.8-6.4 3-10"></path><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"></path><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"></path></svg>
|
||||
pansy
|
||||
</span>
|
||||
<a href="Pansy Gardens.dc.html">Gardens</a>
|
||||
<a href="Pansy Plants.dc.html">Plants</a>
|
||||
<span style="margin-left:auto;display:flex;align-items:center;gap:10px">
|
||||
<button class="btn btn-icon btn-secondary" style="border-radius:999px" onClick="{{ onTheme }}" title="{{ themeTitle }}">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><path d="{{ themeD1 }}"></path><path d="{{ themeD2 }}"></path><path d="{{ themeD3 }}"></path></svg>
|
||||
</button>
|
||||
<a href="Pansy Settings.dc.html" aria-current="page" class="btn btn-icon btn-secondary" style="border-radius:999px;background:var(--color-accent-100)" title="Settings">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z"></path></svg>
|
||||
</a>
|
||||
<a href="Pansy Login.dc.html" style="width:32px;height:32px;border-radius:999px;background:var(--color-accent-2-300);color:var(--color-accent-2-800);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;text-decoration:none">S</a>
|
||||
</span>
|
||||
</nav>
|
||||
<div style="max-width:720px;margin:0 auto;padding:28px 24px 56px;display:flex;flex-direction:column;gap:18px">
|
||||
<div style="display:flex;align-items:baseline;gap:12px">
|
||||
<h2 style="margin:0">Settings</h2>
|
||||
<span class="tag tag-accent-2" style="border-radius:999px">admin</span>
|
||||
<span style="font-size:13px;color:var(--p-ink-mute);font-weight:600">Instance-wide · everyone on this pansy</span>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
|
||||
<h5 style="margin:0">Appearance</h5>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:160px">
|
||||
<span style="font-size:13.5px;font-weight:600">Theme</span>
|
||||
<span style="font-size:12px;color:var(--p-ink-mute)">System follows your device</span>
|
||||
</span>
|
||||
<span style="display:flex;background:var(--color-neutral-200);border-radius:999px;padding:3px">
|
||||
<sc-for list="{{ themeOpts }}" as="t"><button onClick="{{ t.onClick }}" style="{{ t.style }}">{{ t.label }}</button></sc-for>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
|
||||
<h5 style="margin:0">Who gets in</h5>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<span style="font-size:13.5px;font-weight:600;flex:1;min-width:160px">Self-service signup</span>
|
||||
<span style="display:flex;background:var(--color-neutral-200);border-radius:999px;padding:3px">
|
||||
<sc-for list="{{ regOpts }}" as="r"><button onClick="{{ r.onClick }}" style="{{ r.style }}">{{ r.label }}</button></sc-for>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:160px">
|
||||
<span style="font-size:13.5px;font-weight:600">Local passwords</span>
|
||||
<span style="font-size:12px;color:var(--p-ink-mute)">Turn off for pure-Authentik sign-in</span>
|
||||
</span>
|
||||
<button onClick="{{ onLocalAuth }}" style="{{ localAuthStyle }}">{{ localAuthLabel }}</button>
|
||||
</div>
|
||||
<div class="field"><label>OIDC issuer</label>
|
||||
<input class="input" style="border-radius:999px" value="https://auth.dudenhoeffer.casa/application/o/pansy/" readOnly>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--p-ink-mute);line-height:1.5">Client ID and secret live in the environment, not here. The first account ever registered became admin — that's you.</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<h5 style="margin:0">Garden assistant</h5>
|
||||
<span class="tag tag-accent-2" style="border-radius:999px">{{ agentStatus }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<span style="display:flex;flex-direction:column;gap:1px;flex:1;min-width:160px">
|
||||
<span style="font-size:13.5px;font-weight:600">Assistant</span>
|
||||
<span style="font-size:12px;color:var(--p-ink-mute)">Swaps live — no restart</span>
|
||||
</span>
|
||||
<button onClick="{{ onAgent }}" style="{{ agentStyle }}">{{ agentLabel }}</button>
|
||||
</div>
|
||||
<div class="field"><label>Chat model — blank inherits the environment default</label>
|
||||
<input class="input" style="border-radius:999px" value="{{ model }}" onChange="{{ onModel }}" placeholder="ollama-cloud/glm-5.2:cloud">
|
||||
</div>
|
||||
<div class="field"><label>Vision model — reads seed packets; must be vision-capable</label>
|
||||
<input class="input" style="border-radius:999px" value="{{ vModel }}" onChange="{{ onVModel }}" placeholder="empty — packet scanning off">
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--p-ink-mute);line-height:1.5">The API key stays in the environment on purpose — a secret in the database would ride along in every backup. Precedence: this page → env → default.</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--color-neutral-100);border:1px solid var(--color-divider);border-radius:var(--radius-lg);padding:22px 24px;display:flex;flex-direction:column;gap:14px">
|
||||
<h5 style="margin:0">You</h5>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||
<div class="field" style="flex:1;min-width:180px"><label>Display name</label><input class="input" style="border-radius:999px" value="{{ dName }}" onChange="{{ onDName }}"></div>
|
||||
<div class="field" style="flex:1;min-width:180px"><label>Email</label><input class="input" style="border-radius:999px" value="[email protected]" readOnly></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end">
|
||||
<a href="Pansy Login.dc.html" class="btn btn-ghost" style="border-radius:999px;text-decoration:none">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
state = { reg: 'closed', localAuth: true, agent: true, model: 'ollama-cloud/glm-5.2:cloud', vModel: 'ollama-cloud/qwen3.5-vl:cloud', dName: 'Steve Dudenhoeffer' };
|
||||
componentDidMount() { this.themeMount(); }
|
||||
componentWillUnmount() { this._unwatchTheme && this._unwatchTheme(); }
|
||||
themeMount() {
|
||||
const PT = window.PansyTheme; if (!PT) return;
|
||||
this.setState({ themePref: PT.get() });
|
||||
this._unwatchTheme = PT.watch(() => { if ((this.state.themePref || 'system') === 'system') PT.apply(PT.isDark('system')); });
|
||||
}
|
||||
cycleTheme = () => { const PT = window.PansyTheme; if (!PT) return; const p = PT.next(this.state.themePref || 'system'); PT.set(p); PT.apply(PT.isDark(p)); this.setState({ themePref: p }); };
|
||||
themeVals() {
|
||||
const p = this.state.themePref || 'system';
|
||||
const d = window.PansyTheme ? window.PansyTheme.icon(p) : ['', '', ''];
|
||||
return { themeD1: d[0], themeD2: d[1], themeD3: d[2], onTheme: this.cycleTheme, themeTitle: 'Theme: ' + p };
|
||||
}
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const toggle = (on) => ({ cursor: 'pointer', border: 'none', borderRadius: '999px', padding: '6px 18px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: on ? 'var(--color-accent-2-300)' : 'var(--color-neutral-300)', color: on ? 'var(--color-accent-2-800)' : 'var(--p-ink-soft)' });
|
||||
return {
|
||||
...this.themeVals(),
|
||||
themeOpts: ['system', 'light', 'dark'].map(v => ({
|
||||
key: v, label: v[0].toUpperCase() + v.slice(1),
|
||||
style: { padding: '5px 16px', cursor: 'pointer', border: 'none', borderRadius: '999px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: (st.themePref || 'system') === v ? 'var(--color-neutral-100)' : 'transparent', color: (st.themePref || 'system') === v ? 'var(--color-text)' : 'var(--p-ink-soft)', boxShadow: (st.themePref || 'system') === v ? 'var(--shadow-sm)' : 'none' },
|
||||
onClick: () => { const PT = window.PansyTheme; if (!PT) return; PT.set(v); PT.apply(PT.isDark(v)); this.setState({ themePref: v }); },
|
||||
})),
|
||||
regOpts: [['open', 'Open'], ['closed', 'Closed']].map(([id, label]) => ({
|
||||
key: id, label,
|
||||
style: { padding: '5px 16px', cursor: 'pointer', border: 'none', borderRadius: '999px', fontFamily: 'var(--font-body)', fontSize: '12.5px', fontWeight: 700, background: st.reg === id ? 'var(--color-neutral-100)' : 'transparent', color: st.reg === id ? 'var(--color-text)' : 'var(--p-ink-soft)', boxShadow: st.reg === id ? 'var(--shadow-sm)' : 'none' },
|
||||
onClick: () => this.setState({ reg: id }),
|
||||
})),
|
||||
onLocalAuth: () => this.setState(s => ({ localAuth: !s.localAuth })),
|
||||
localAuthStyle: toggle(st.localAuth), localAuthLabel: st.localAuth ? 'On' : 'Off',
|
||||
onAgent: () => this.setState(s => ({ agent: !s.agent })),
|
||||
agentStyle: toggle(st.agent), agentLabel: st.agent ? 'On' : 'Off',
|
||||
agentStatus: st.agent ? 'key present · glm-5.2 live' : 'off — key still in env',
|
||||
model: st.model, onModel: (e) => this.setState({ model: e.target.value }),
|
||||
vModel: st.vModel, onVModel: (e) => this.setState({ vModel: e.target.value }),
|
||||
dName: st.dName, onDName: (e) => this.setState({ dName: e.target.value }),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,125 +0,0 @@
|
||||
# Handoff: Pansy UI redesign
|
||||
|
||||
## Overview
|
||||
A full UI/UX replacement for **pansy**, the self-hosted garden planner (Go backend + JSON API, see repo `DESIGN.md`). This package covers every screen: the garden editor (desktop workspace + phone layout), gardens list, plants catalog with seed lots and packet scanning, instance settings, and login — plus a light/dark theme system defaulting to the OS preference.
|
||||
|
||||
## About the design files
|
||||
The `.dc.html` files in this bundle are **design references created in HTML** — working prototypes showing intended look and behavior, not production code to copy. The task is to **recreate these designs in the pansy codebase**. The existing frontend (React 19 + TS + Vite + Tailwind 4) is explicitly up for replacement: keep it, or choose anything that renders SVG and talks JSON. Everything here was deliberately built with framework-agnostic primitives (plain SVG canvas, pointer events, CSS custom properties) so it ports anywhere. The Go backend and its API are the fixed contract — this design maps 1:1 onto the existing endpoints.
|
||||
|
||||
The prototypes carry in-memory demo data (the real 9-bed garden: five 3'×6' beds, four 2'×8' beds, bags, buckets). Wherever demo state exists, the matching API call is named below.
|
||||
|
||||
## Fidelity
|
||||
**High-fidelity.** Colors, typography, spacing, radii, and interactions are final. Recreate pixel-perfectly. The visual system is "Organic": warm cream ground, terracotta + sage accents, Caprasimo display over Figtree body, everything over-rounded (pills, 16–28px radii). Fonts are Google Fonts: `Caprasimo:wght@400` and `Figtree:wght@400;600;700`.
|
||||
|
||||
## Files
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `Pansy Editor.dc.html` | The editor — BOTH desktop and phone layouts in one file (breakpoint 760px on container width) |
|
||||
| `Pansy Gardens.dc.html` | Gardens list + new-garden and share dialogs |
|
||||
| `Pansy Plants.dc.html` | Plant catalog + seed lots + scan-packet and add-plant dialogs |
|
||||
| `Pansy Settings.dc.html` | Instance settings (admin) + appearance |
|
||||
| `Pansy Login.dc.html` | Login |
|
||||
| `Pansy Phone Preview.dc.html` | The editor mounted in an iPhone frame (`ios-frame.jsx`) — preview aid only, do not implement |
|
||||
| `pansy-theme.js` | The theme mechanism: dark-mode token overrides + system-pref logic. Port this pattern directly |
|
||||
| `_ds/organic-…/styles.css` | The design-token stylesheet (CSS custom properties + component classes). The single source of visual truth |
|
||||
|
||||
Each `.dc.html` is template + logic in one file; read the markup for exact inline styles and the `class Component` script for behavior math.
|
||||
|
||||
## Screenshots
|
||||
`screenshots/` holds visual ground truth for every screen in both modes: `editor-desktop`, `editor-phone` (in the device frame), `gardens`, `plants`, `settings`, `login` — each as `-light.png` and `-dark.png`. When a spec and a screenshot disagree, flag it rather than guessing; the live HTML file is the tiebreaker.
|
||||
|
||||
## Design tokens
|
||||
|
||||
### Core (light) — from `styles.css`
|
||||
- Ground `--color-bg: #f5ead8` · surface `#ebddc5` · text `#201e1d`
|
||||
- Accent (terracotta) `#c67139` with 100–900 ramp (`#fff2eb → #402310`); accent-2 (sage) `#7a8a5e` with ramp (`#f0fae1 → #272e1b`)
|
||||
- Neutral ramp `#f9f4ed → #2e2b25`
|
||||
- Divider: `color-mix(in srgb, #201e1d 16%, transparent)`
|
||||
- Radii: sm 8 / md 16 / lg 28 / pills `999px` (all controls are pills)
|
||||
- Shadows: `--shadow-sm/md/lg` (ink-tinted; see styles.css)
|
||||
- Type: h-font Caprasimo 400; body Figtree; base 15px/1.55. Page titles h2 32px, card titles ~16–19px Caprasimo, UI labels 12.5–13.5px Figtree 600–700, metadata 11.5–12.5px at `--p-ink-mute`
|
||||
|
||||
### Pansy canvas + ink tokens (light values; declared per-page in `:root`)
|
||||
```
|
||||
--p-field:#efe3c9 garden field fill --p-grid-ink:#201e1d (grid lines @ .06/.12 opacity)
|
||||
--p-ink-strong:#474238 primary secondary text --p-ink-soft:#645c50 --p-ink-mute:#82796a
|
||||
--p-bed-fill:#e3d0ac / --p-bed-stroke:#b5a37f raised bed
|
||||
--p-ing-fill:#d6bf98 / #b1a07c (dash 10 7) in-ground plot
|
||||
--p-path-fill:#ece1cb / #cabfa6 (dash 3 8) path
|
||||
--p-bag-fill:#d8c5a2 / #a99677 grow bag
|
||||
--p-bkt-fill:#cfc3ad / #9a8d76 container/bucket
|
||||
--p-tree-fill:#dce9c6 / #8fa073 (dash 12 8) tree canopy
|
||||
--p-str-fill:#d9cfbc / #a3947c structure
|
||||
```
|
||||
|
||||
### Dark mode
|
||||
`pansy-theme.js` holds the full override map — dark is implemented **only** by overriding these custom properties on `<html>` (plus `color-scheme`). Key values: bg `#252220`, surface `#33302a`, text `#f1e9da`, cards `#2e2b25`, field `#2b2823`, grid ink flips to `#f5ead8`, accent lifts to `#d67f48`, tag/tint pairs flip (e.g. accent-2-100 → `#2d3520` with accent-2-800 → `#e1eecc`). Plant marker colors do NOT change between modes.
|
||||
- Preference: `'system' | 'light' | 'dark'`, persisted (`localStorage['pansy-theme']` in the prototype; per-user setting in production). Default **system**, live-updates on `prefers-color-scheme` change.
|
||||
- UI: icon button in every nav cycling system→light→dark (monitor/sun/moon, Lucide, stroke 2.75) + a System/Light/Dark segmented control in Settings → Appearance.
|
||||
|
||||
### Plant markers (demo palette)
|
||||
Solid circle in the plant's color + 1–2 letter monogram in Caprasimo `#fffaf1`: Garlic `#97a97c` G · Tomato `#c8553d` T · Cucumber `#6f8f4f` C · Watermelon `#46683c` W · Basil `#5f8f45` B · Pepper `#b2622d` P · Marigold `#d9912f` Ma · Melon `#c2913a` Me. In production the color comes from `plants.color`; derive the monogram from the name. This replaces the old emoji icons.
|
||||
|
||||
## Screens
|
||||
|
||||
### 1. Editor — desktop (≥760px container)
|
||||
Three-region workspace under a nav bar; every region is a `--color-neutral-100` card, `--radius-lg`, 1px divider border, in a 14px-gap grid: `216px | minmax(0,1fr) | 336px`.
|
||||
|
||||
- **Nav**: brand (sprout icon, sage, stroke 2.75 + "pansy" Caprasimo 18), links Gardens/Plants (active = accent), right cluster: theme toggle, settings gear, avatar circle (32px, sage-300 bg).
|
||||
- **Left card — Toolkit**: 7 draggable object kinds (Bed 3'×6', Grow bag 1'4", Container 2', In-ground 6'7", Tree 9'10", Path 3'3"×9'10", Structure 6'7"), each a pill row: mini shape swatch (SVG, true proportions, kind's fill/stroke) + bold 13px label + 11px size in ink-mute. Click arms (accent-200 bg + accent-400 border, crosshair cursor, click canvas to place); drag-drop onto canvas also places. When a bed is **focused** the card swaps to: back chevron + bed name (Caprasimo 16), search input, plant list rows (14px color dot + name + spacing), same arm/drag behavior.
|
||||
- **Center card — canvas**: header row (18px padding): "Home Garden" Caprasimo 19 + size `40′ × 28′` ink-mute + focus crumb (`/ Bed 2` accent-700) + right cluster (wraps at narrow widths): season segmented pill control [2025 | 2026 | 2027 plan] on neutral-200 track (active = neutral-100 pill + shadow-sm) and an Undo pill button (disabled at 45% opacity when stack empty). A status banner strip (accent-2-200 bg / accent-2-800 text, 13px) appears under the header when season ≠ 2026: 2025 = read-only; 2027 plan = "separate copy" note.
|
||||
- **Canvas (SVG)**: field = rounded rect (rx 18) `--p-field`, stroke accent-2-500 3px/scale; 1ft grid minor / 5ft major lines. Objects positioned by center + rotation (`translate(x y) rotate(deg)`), stroke width 2.5/scale (3.5 accent when selected). Bed names in 13px/scale above plantable objects; centered inside non-plantable ones. Plops (planting patches) = circles in plant color, opacity .94, monogram shown when `r·scale ≥ 9px`, name below when `≥ 34px` (semantic zoom). Selection = dashed accent outline offset 7/scale + size label (`3′ × 6′`) under the object. Ghost preview follows cursor when armed, 55% opacity dashed.
|
||||
- **Zoom pill** floating bottom-right of canvas: − / fit / +.
|
||||
- **Right card — rail** with 4 pill tabs: **Plot** (garden summary: counts line + full plant roster with dots and "64 in Long bed A"-style locations; or, with a selection, the inspector: name input, kind tag + size, "Growing:" roster, actions [Plant this · rotate 90° · delete]; plop selection: plant swatch + count + patch size + "Pull it out"), **Journal** (entry cards: object tag accent-2-700 + date, 13px body; input + add pill, Enter submits, attaches to selection/focus/garden), **History** ("Every change — yours or the assistant's — is one undoable step." + newest-first op pills with timestamps + "Undo the last step"), **Assistant** (chat bubbles: user right-aligned accent-200/accent-900 radius 18/18/4/18, assistant left bg/divider 18/18/18/4; input + send).
|
||||
|
||||
### 2. Editor — phone (<760px container)
|
||||
Same file, same state, different chrome (matches repo DESIGN.md #99/#101):
|
||||
- **Header** 10px padding: back circle button (38px; exits focus first, else → Gardens), garden/bed name Caprasimo 17 (ellipsis), right: theme button, season chip (tap cycles seasons, shows "26"/"27 plan"), undo circle button.
|
||||
- **Canvas** fills the middle; floating fit button bottom-right. **Pinch to zoom** (two-pointer), one-finger pan/drag, tap = select, touch drag threshold 7px (mouse 3px).
|
||||
- **Peek panel** (inspector on selection, or Journal/Assistant modes): docked between canvas and mode bar, max-height 45%, radius 22px top corners, shadow-lg, title + close X; canvas stays visible above it. Inputs inside are 16px font (prevents iOS zoom). All hit targets ≥ 44px.
|
||||
- **Tool strip** (Build or Plants mode, hidden while peek is open): horizontal scroll row of pill chips (min-height 44) above the mode bar. In focus + Plants mode the first chip is a primary "Done".
|
||||
- **Mode bar**, always visible, surface bg, 4 items (icon 19px + 11px label): Build, Plants, Journal, Assistant; active = accent-200 pill. Bottom padding `calc(6px + env(safe-area-inset-bottom))`.
|
||||
- Focusing a bed on phone auto-switches to Plants mode; plants can also be tapped straight into any bed without focusing.
|
||||
|
||||
### 3. Gardens list
|
||||
Nav + `max-width 1080px` page. Title row: "Gardens" h2 + tagline ink-mute + "New garden" primary pill. Card grid `repeat(auto-fill, minmax(300px,1fr))`, 18px gap. Each card: SVG plot thumbnail (150px band, `--p-field` bg, real object layout + plant-colored dots/pills), name Caprasimo 18 (+ optional `plan` accent tag), size right-aligned, 13px meta line, optional "Shared with lauren@ · editor" in accent-2-700, footer: Open (primary pill, flex 1) + share / copy / delete icon buttons. Dialogs: **New garden** (name, width/height in ft — note: API stores cm; "Break ground" primary) and **Share** (invite by email + role chips toggling viewer/editor on tap, read-only-link toggle + dashed link pill). Deep-copy card behavior mirrors `POST /gardens/:id/copy`.
|
||||
|
||||
### 4. Plants catalog
|
||||
Title row + two actions: "Scan a packet" (secondary, camera icon) and "Add a plant" (primary). Filter row: search input (260px) + category pill chips (All/Vegetables/Herbs/Flowers/Fruit), live filtering. Card grid `minmax(240px,1fr)`: 40px color swatch with Caprasimo monogram, name Caprasimo 16.5 (ellipsis), sub line `Category · spacing · days`, `built-in` neutral tag for seeded plants, seed-lot summary line; clicking expands the card (accent border) to show lot cards (vendor bold + "packed for YYYY" + detail line with derived remaining) — `remaining` is **derived** server-side, never stored.
|
||||
**Scan-packet dialog** (the #81 flow): step 1 = dashed drop zone + camera icon + copy "The vision model reads it into fields. It only reads; nothing is saved until you confirm." Step 2 = extracted fields grid (Variety/Vendor/Packed for/Quantity) + ranked match list as radio pills ("Garlic (built-in)" / "Garlic — Music (yours) · best match" / "Create a new plant") + "Add the lot" primary. **Never auto-create — the user confirms the match.**
|
||||
**Add-plant dialog**: name, category select, spacing (in), marker color swatch row (6 curated swatches, accent ring on selection).
|
||||
|
||||
### 5. Settings (admin)
|
||||
`max-width 720px` column of cards: **Appearance** (theme seg), **Who gets in** (signup Open/Closed seg, Local passwords toggle pill with "pure-Authentik" note, read-only OIDC issuer input, first-user-is-admin note), **Garden assistant** (live status tag e.g. "key present · glm-5.2 live", on/off toggle, chat model + vision model inputs with inherit-from-env placeholders, key-stays-in-env note — precedence Settings → env → default), **You** (display name, email read-only, Sign out ghost). Maps to `GET/PATCH /settings` + `/capabilities`.
|
||||
|
||||
### 6. Login
|
||||
Centered 400px column over two soft blurred accent circles (decoration, 50–55% opacity): sprout 44px + "pansy" Caprasimo 40 + tagline "Plan the plot. Keep the notes. Grow the thing." Card: email + password fields (16px font), "Into the garden" primary block pill, "or" divider, "Sign in with Authentik" secondary block (lock icon; label from `PANSY_OIDC_BUTTON_LABEL`; hide per `GET /auth/providers`), footer "New here? Create an account — the first one becomes admin."
|
||||
|
||||
## Interactions & behavior (editor core)
|
||||
|
||||
**Viewport**: world = garden cm, screen = `translate(tx,ty) scale(s)` on one SVG group (s = px/cm, clamp 0.12–8). Wheel zoom to cursor (`s · e^(−deltaY·0.0016)`, non-passive listener). Pinch: scale by finger-distance ratio, keep world point under the centroid fixed. Fit: `s = min((w−2p)/GW, (h−2p)/GH)`, pad 40 desktop / 20 phone, centered. Camera animates on fit/focus with `transform .48s cubic-bezier(.22,.85,.3,1)`; drags/zooms are transition-free. Refit when container size changes >60px (ResizeObserver) or mobile/desktop flips.
|
||||
|
||||
**Placement & drag**: object drags snap center to a 3in (7.62cm) grid; drag commits ONE change (PATCH on drop, not per frame). Plops live in the parent object's local frame (rotate/move the bed moves its plants); drag clamps so a patch may overhang the bed edge by up to r/2 (spacing rule from DESIGN.md). Placing a plop: radius = spacing/2, count derived `max(1, round(πr²/spacing²))`. Click vs drag disambiguated by movement threshold. Escape ladder: disarm → deselect → unfocus. Delete/Backspace deletes selection (never while typing in inputs).
|
||||
|
||||
**Focus (plant a bed)**: double-click (desktop) or inspector "Plant this" — camera zooms to the bed (≈2.1× margin desktop, 1.35× phone, cap s=6), siblings dim to 30% opacity (their plops 22%), palette/strip swaps to plants. Same canvas, no separate view; mirror to `?focus=objectId`.
|
||||
|
||||
**Undo/History**: every operation (add, move, rotate, delete, plant, pull, clear) is one undoable step with a human label ("Planted garlic in Long bed A"). Maps directly to the change-set API (`GET /gardens/:id/history`, `POST /change-sets/:id/revert`); the prototype's snapshot stack is a stand-in.
|
||||
|
||||
**Seasons**: the seg switches `?year=` on `GET /gardens/:id/full`. Past seasons render read-only (edit handlers guard + banner). "2027 plan" opens the copied plan garden (from `/copy`) — visually identical editing with a persistent banner.
|
||||
|
||||
**Read-only viewers** (share role viewer / public token): same guard path as the 2025 season — canvas renders, all mutation entry points disabled.
|
||||
|
||||
## State management (suggested shape)
|
||||
- Server state: `GET /gardens/:id/full` (garden + objects + plantings + plants) keyed per garden+year; optimistic mutations with `version` guard — on 409 roll back and refetch.
|
||||
- Ephemeral UI state: `{tx, ty, s}`, `selection {type: 'object'|'plop', id} | null`, `focusId | null`, `armedKind | null`, `armedPlant | null`, season/year, rail tab, phone mode ('build'|'plants'|'journal'|'chat'), theme pref.
|
||||
- Journal (`/gardens/:id/journal`) and assistant thread (`/gardens/:id/agent/history`, `POST /agent/chat` SSE) load per tab/peek.
|
||||
|
||||
## Assets
|
||||
No binary assets. Icons are Lucide (https://lucide.dev) inlined at **stroke-width 2.75**, round caps/joins: sprout (brand + Plants mode), shovel (Build), notebook (Journal), message-circle (Assistant), settings, undo-2, rotate-cw, trash-2, plus, minus, maximize (fit), chevron-left/right, x, camera, share-2, copy, lock, monitor/sun/moon (theme), search, send. Object-kind "icons" are not glyphs — they're mini SVG swatches of the kind's actual shape, fill, and stroke.
|
||||
|
||||
## Implementation notes for pansy specifically
|
||||
- Keep rendering as **plain SVG** — the prototypes prove tens of objects + low-hundreds of plops need nothing heavier; native hit-testing does all picking.
|
||||
- The editor's desktop/phone split is **container-width-driven (760px)**, one component tree, two chromes — don't build two apps.
|
||||
- Semantic zoom thresholds that felt right: monogram at `r·s ≥ 9px`, plop name at `≥ 34px`, object labels when `max(w,h)·s > 54px`.
|
||||
- Imperial display is presentation-only: cm → nearest inch, shown as `3′` / `1′6″` / `8″`; API stays metric.
|
||||
- Theme: implement exactly as `pansy-theme.js` does — token overrides on the root element, `color-scheme` set, system watcher. No second stylesheet, no class swapping on every node.
|
||||
@@ -1,11 +0,0 @@
|
||||
/* @ds-bundle: {"format":4,"namespace":"Organic_organi","components":[],"sourceHashes":{},"inlinedExternals":[],"unexposedExports":[]} */
|
||||
|
||||
(() => {
|
||||
|
||||
const __ds_ns = (window.Organic_organi = window.Organic_organi || {});
|
||||
|
||||
const __ds_scope = {};
|
||||
|
||||
(__ds_ns.__errors = __ds_ns.__errors || []);
|
||||
|
||||
})();
|
||||
@@ -1,257 +0,0 @@
|
||||
/* Organic — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caprasimo:wght@400&family=Figtree:wght@400;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--color-bg: #f5ead8;
|
||||
--color-surface: #ebddc5;
|
||||
--color-text: #201e1d;
|
||||
--color-accent: #c67139;
|
||||
--color-accent-2: #7a8a5e;
|
||||
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
|
||||
|
||||
/* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
|
||||
same step of any role matches the others in visual value. */
|
||||
--color-neutral-100: #f9f4ed;
|
||||
--color-neutral-200: #eee7db;
|
||||
--color-neutral-300: #dcd3c4;
|
||||
--color-neutral-400: #c0b6a5;
|
||||
--color-neutral-500: #a19786;
|
||||
--color-neutral-600: #82796a;
|
||||
--color-neutral-700: #645c50;
|
||||
--color-neutral-800: #474238;
|
||||
--color-neutral-900: #2e2b25;
|
||||
|
||||
--color-accent-100: #fff2eb;
|
||||
--color-accent-200: #ffe1d0;
|
||||
--color-accent-300: #ffc6a5;
|
||||
--color-accent-400: #f6a06b;
|
||||
--color-accent-500: #d67f48;
|
||||
--color-accent-600: #b2622d;
|
||||
--color-accent-700: #8c491a;
|
||||
--color-accent-800: #643312;
|
||||
--color-accent-900: #402310;
|
||||
|
||||
--color-accent-2-100: #f0fae1;
|
||||
--color-accent-2-200: #e1eecc;
|
||||
--color-accent-2-300: #ccdbb2;
|
||||
--color-accent-2-400: #aebf92;
|
||||
--color-accent-2-500: #8fa073;
|
||||
--color-accent-2-600: #728157;
|
||||
--color-accent-2-700: #56633f;
|
||||
--color-accent-2-800: #3d472b;
|
||||
--color-accent-2-900: #272e1b;
|
||||
|
||||
--font-heading: "Caprasimo", system-ui, sans-serif;
|
||||
--font-heading-weight: 400;
|
||||
--font-body: "Figtree", system-ui, sans-serif;
|
||||
|
||||
--space-1: 4.4px;
|
||||
--space-2: 8.8px;
|
||||
--space-3: 13.2px;
|
||||
--space-4: 17.6px;
|
||||
--space-6: 26.4px;
|
||||
--space-8: 35.2px;
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 28px;
|
||||
|
||||
/* Elevation — derived from the ground: soft ink-tinted shadows on a
|
||||
light theme, a hairline edge + ambient darkness on a dark one. */
|
||||
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
|
||||
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
|
||||
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
|
||||
|
||||
.washed{filter:saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
Components — built with the tokens above. Plain CSS
|
||||
on plain HTML: no JavaScript, no build step. Each class is documented in
|
||||
readme.md and demonstrated in foundations/ and components/.
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
|
||||
}
|
||||
h1 { font-size: 42px; }
|
||||
h2 { font-size: 32px; }
|
||||
h3 { font-size: 25px; }
|
||||
h4 { font-size: 20px; }
|
||||
h5 { font-size: 16px; }
|
||||
h6 { font-size: 13px; }
|
||||
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
p { margin: 0 0 var(--space-3); }
|
||||
a { color: var(--color-accent); text-underline-offset: 3px; }
|
||||
img { display: block; max-width: 100%; }
|
||||
figure { margin: 0; }
|
||||
figcaption {
|
||||
font-size: 11px; margin-top: var(--space-1);
|
||||
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||
}
|
||||
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
|
||||
:focus { outline: none; }
|
||||
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
|
||||
|
||||
/* — rules — */
|
||||
.hr {
|
||||
height: 1px; border: 0; margin: var(--space-4) 0;
|
||||
background: var(--color-divider);
|
||||
}
|
||||
|
||||
/* — buttons — */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
cursor: pointer; text-decoration: none;
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
|
||||
the pair sits side by side in sign-up rows */
|
||||
background: transparent; border: 1px solid transparent;
|
||||
padding: var(--space-2) calc(var(--space-3) * 1.2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.btn svg { display: block; }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
|
||||
.btn-primary:hover { background: var(--color-accent-600); }
|
||||
.btn-primary:active { background: var(--color-accent-700); }
|
||||
.btn-secondary { border-color: var(--color-divider); }
|
||||
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
|
||||
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
|
||||
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
|
||||
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
|
||||
.btn-icon { width: 36px; height: 36px; padding: 0; }
|
||||
.btn-block { width: 100%; margin-top: var(--space-2); }
|
||||
|
||||
/* — forms — */
|
||||
.field > label {
|
||||
display: block; font-size: 12px; margin-bottom: 5px;
|
||||
color: color-mix(in srgb, var(--color-text) 70%, transparent);
|
||||
}
|
||||
.input {
|
||||
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
|
||||
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
|
||||
}
|
||||
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
|
||||
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
|
||||
textarea.input { min-height: 90px; resize: vertical; }
|
||||
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
|
||||
.radio input, .seg-opt input {
|
||||
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
|
||||
}
|
||||
.radio .dot {
|
||||
width: 16px; height: 16px; flex: none; border-radius: 50%;
|
||||
border: 1.5px solid var(--color-divider);
|
||||
}
|
||||
.radio:hover .dot { border-color: var(--color-accent); }
|
||||
.radio input:checked + .dot {
|
||||
border-color: var(--color-accent); background: var(--color-accent);
|
||||
box-shadow: inset 0 0 0 4px var(--color-bg);
|
||||
}
|
||||
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||
.seg {
|
||||
display: inline-flex; overflow: hidden;
|
||||
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
|
||||
}
|
||||
.seg-opt {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 12px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
|
||||
.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
|
||||
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
|
||||
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
|
||||
|
||||
/* — cards — */
|
||||
.card {
|
||||
display: flex; flex-direction: column; gap: var(--space-2);
|
||||
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
|
||||
}
|
||||
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
|
||||
.card-title {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 17px; line-height: 1.2;
|
||||
}
|
||||
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
|
||||
.card-meta {
|
||||
display: flex; align-items: center; gap: 6px; font-size: 11px;
|
||||
color: color-mix(in srgb, var(--color-text) 50%, transparent);
|
||||
}
|
||||
.elev-sm { box-shadow: var(--shadow-sm); }
|
||||
.elev-md { box-shadow: var(--shadow-md); }
|
||||
.elev-lg { box-shadow: var(--shadow-lg); }
|
||||
|
||||
/* — tags — */
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; font-size: 11px;
|
||||
letter-spacing: 0.02em; padding: 3px 10px;
|
||||
border-radius: calc(var(--radius-md) * 0.75);
|
||||
}
|
||||
.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
|
||||
.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
|
||||
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
|
||||
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
|
||||
|
||||
/* — navigation — */
|
||||
.nav {
|
||||
display: flex; align-items: center; gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: none;
|
||||
}
|
||||
.nav-brand {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 18px; margin-right: auto;
|
||||
}
|
||||
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
|
||||
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
|
||||
|
||||
/* — tables — */
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.table th {
|
||||
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--color-text) 60%, transparent);
|
||||
padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
.table td {
|
||||
padding: var(--space-2);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||
}
|
||||
.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
|
||||
|
||||
/* — dialog — */
|
||||
.dialog-backdrop {
|
||||
position: fixed; inset: 0; display: grid; place-items: center;
|
||||
padding: var(--space-4);
|
||||
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
|
||||
}
|
||||
.dialog {
|
||||
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
|
||||
padding: var(--space-4); border-radius: var(--radius-lg);
|
||||
background: var(--color-surface); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.dialog-title {
|
||||
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
|
||||
font-size: 20px;
|
||||
}
|
||||
.dialog-body { font-size: 14px; opacity: 0.85; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
|
||||
|
||||
/* — rounded frame: everything softens, small controls go pill — */
|
||||
.card, .dialog { border-radius: calc(var(--radius-lg) * 1.15); }
|
||||
.btn, .tag, .seg, .input { border-radius: 999px; }
|
||||
.input { padding-inline: 14px; }
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
|
||||
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
|
||||
|
||||
/* BEGIN USAGE */
|
||||
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
|
||||
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
|
||||
// Exports (to window): IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
|
||||
//
|
||||
// Usage — wrap your screen content in <IOSDevice> to get the bezel, status bar
|
||||
// and home indicator (props: title, dark, keyboard):
|
||||
//
|
||||
// <IOSDevice title="Settings">
|
||||
// ...your screen content...
|
||||
// </IOSDevice>
|
||||
// <IOSDevice dark title="Search" keyboard>…</IOSDevice>
|
||||
/* END USAGE */
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status bar
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSStatusBar({ dark = false, time = '9:41' }) {
|
||||
const c = dark ? '#fff' : '#000';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
|
||||
padding: '21px 24px 19px', boxSizing: 'border-box',
|
||||
position: 'relative', zIndex: 20, width: '100%',
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
|
||||
<span style={{
|
||||
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
|
||||
fontSize: 17, lineHeight: '22px', color: c,
|
||||
}}>{time}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
|
||||
<svg width="19" height="12" viewBox="0 0 19 12">
|
||||
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
|
||||
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
|
||||
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
|
||||
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
|
||||
</svg>
|
||||
<svg width="17" height="12" viewBox="0 0 17 12">
|
||||
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
|
||||
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
|
||||
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
|
||||
</svg>
|
||||
<svg width="27" height="13" viewBox="0 0 27 13">
|
||||
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
|
||||
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
|
||||
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Liquid glass pill — blur + tint + shine
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSGlassPill({ children, dark = false, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 44, minWidth: 44, borderRadius: 9999,
|
||||
position: 'relative', overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: dark
|
||||
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
|
||||
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
|
||||
...style,
|
||||
}}>
|
||||
{/* blur + tint */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
|
||||
}} />
|
||||
{/* shine */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Navigation bar — glass pills + large title
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
|
||||
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const pillIcon = (content) => (
|
||||
<IOSGlassPill dark={dark}>
|
||||
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{content}
|
||||
</div>
|
||||
</IOSGlassPill>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
}}>
|
||||
{/* back chevron */}
|
||||
{pillIcon(
|
||||
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
|
||||
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{/* trailing ellipsis */}
|
||||
{trailingIcon && pillIcon(
|
||||
<svg width="22" height="6" viewBox="0 0 22 6">
|
||||
<circle cx="3" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="11" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="19" cy="3" r="2.5" fill={muted}/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{/* large title */}
|
||||
<div style={{
|
||||
padding: '0 16px',
|
||||
fontFamily: '-apple-system, system-ui',
|
||||
fontSize: 34, fontWeight: 700, lineHeight: '41px',
|
||||
color: text, letterSpacing: 0.4,
|
||||
}}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Grouped list (inset card, r:26) + row (52px)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
|
||||
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', minHeight: 52,
|
||||
padding: '0 16px', position: 'relative',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
letterSpacing: -0.43,
|
||||
}}>
|
||||
{icon && (
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 7, background: icon,
|
||||
marginRight: 12, flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
<div style={{ flex: 1, color: text }}>{title}</div>
|
||||
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
|
||||
{chevron && (
|
||||
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
|
||||
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, right: 0,
|
||||
left: icon ? 58 : 16, height: 0.5, background: sep,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IOSList({ header, children, dark = false }) {
|
||||
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const bg = dark ? '#1C1C1E' : '#fff';
|
||||
return (
|
||||
<div>
|
||||
{header && (
|
||||
<div style={{
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 13,
|
||||
color: hc, textTransform: 'uppercase',
|
||||
padding: '8px 36px 6px', letterSpacing: -0.08,
|
||||
}}>{header}</div>
|
||||
)}
|
||||
<div style={{
|
||||
background: bg, borderRadius: 26,
|
||||
margin: '0 16px', overflow: 'hidden',
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Device frame
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSDevice({
|
||||
children, width = 402, height = 874, dark = false,
|
||||
title, keyboard = false,
|
||||
}) {
|
||||
return (
|
||||
// data-om-starter: inert presence marker — Claude Design's starter-usage
|
||||
// probe reads it; it renders nothing. Keep it on this root element.
|
||||
<div data-om-starter="ios-frame" style={{
|
||||
width, height, borderRadius: 48, overflow: 'hidden',
|
||||
position: 'relative', background: dark ? '#000' : '#F2F2F7',
|
||||
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
|
||||
fontFamily: '-apple-system, system-ui, sans-serif',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
}}>
|
||||
{/* dynamic island */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
|
||||
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
|
||||
}} />
|
||||
{/* status bar (absolute) */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
|
||||
<IOSStatusBar dark={dark} />
|
||||
</div>
|
||||
{/* nav + content */}
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||
{keyboard && <IOSKeyboard dark={dark} />}
|
||||
</div>
|
||||
{/* home indicator — always on top */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
|
||||
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
|
||||
paddingBottom: 8, pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 139, height: 5, borderRadius: 100,
|
||||
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Keyboard — iOS 26 liquid glass
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSKeyboard({ dark = false }) {
|
||||
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
|
||||
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
|
||||
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
|
||||
|
||||
// special-key icons
|
||||
const icons = {
|
||||
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
|
||||
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
};
|
||||
|
||||
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
|
||||
<div key={k} style={{
|
||||
height: 42, borderRadius: 8.5,
|
||||
flex: flex ? 1 : undefined, width: w, minWidth: 0,
|
||||
background: ret ? '#08f' : keyBg,
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: '-apple-system, "SF Compact", system-ui',
|
||||
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
|
||||
}}>{content}</div>
|
||||
);
|
||||
|
||||
const row = (keys, pad = 0) => (
|
||||
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
|
||||
{keys.map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
|
||||
padding: '11px 0 2px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
boxShadow: dark
|
||||
? '0 -2px 20px rgba(0,0,0,0.09)'
|
||||
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
|
||||
}}>
|
||||
{/* liquid glass bg — same recipe as nav pills */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* autocorrect bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 20, alignItems: 'center',
|
||||
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{['"The"', 'the', 'to'].map((w, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
|
||||
<div style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
|
||||
}}>{w}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* key layout */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 13,
|
||||
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||
{row(['a','s','d','f','g','h','j','k','l'], 20)}
|
||||
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
|
||||
{key(icons.shift, { w: 45, k: 'shift' })}
|
||||
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
|
||||
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
{key(icons.del, { w: 45, k: 'del' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
|
||||
{key('', { flex: true, k: 'space' })}
|
||||
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* bottom spacer (emoji+mic area, icons omitted) */}
|
||||
<div style={{ height: 56, width: '100%', position: 'relative' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
// Pansy theme — light is the stylesheet default; dark overrides the tokens on <html>.
|
||||
// Pref ('system'|'light'|'dark') persists in localStorage and is shared by every page.
|
||||
(function () {
|
||||
const KEY = 'pansy-theme';
|
||||
const DARK = {
|
||||
'--color-bg': '#252220', '--color-surface': '#33302a', '--color-text': '#f1e9da',
|
||||
'--color-divider': 'color-mix(in srgb, #f5ead8 16%, transparent)',
|
||||
'--color-accent': '#d67f48',
|
||||
'--color-neutral-100': '#2e2b25', '--color-neutral-200': '#3a362f', '--color-neutral-300': '#474238',
|
||||
'--color-neutral-400': '#645c50', '--color-neutral-500': '#82796a', '--color-neutral-800': '#dcd3c4',
|
||||
'--color-accent-100': '#3d2c1d', '--color-accent-200': '#59331a', '--color-accent-300': '#8c491a',
|
||||
'--color-accent-400': '#d67f48', '--color-accent-700': '#f6a06b', '--color-accent-800': '#ffd9bd', '--color-accent-900': '#ffe9da',
|
||||
'--color-accent-2-200': '#333d24', '--color-accent-2-100': '#2d3520', '--color-accent-2-300': '#3d472b', '--color-accent-2-500': '#728157',
|
||||
'--color-accent-2-600': '#aebf92', '--color-accent-2-700': '#ccdbb2', '--color-accent-2-800': '#e1eecc',
|
||||
'--shadow-sm': '0 1px 2px rgba(0,0,0,0.4)', '--shadow-md': '0 3px 10px rgba(0,0,0,0.45)', '--shadow-lg': '0 12px 32px rgba(0,0,0,0.55)',
|
||||
'--p-field': '#2b2823', '--p-grid-ink': '#f5ead8',
|
||||
'--p-ink-strong': '#d9d0bf', '--p-ink-soft': '#b3a992', '--p-ink-mute': '#8f8674',
|
||||
'--p-bed-fill': '#4a4131', '--p-bed-stroke': '#6d5f47', '--p-ing-fill': '#3f382c', '--p-ing-stroke': '#5c5343',
|
||||
'--p-path-fill': '#312e28', '--p-path-stroke': '#4d473c', '--p-bag-fill': '#463d2f', '--p-bag-stroke': '#6a5d49',
|
||||
'--p-bkt-fill': '#3e382e', '--p-bkt-stroke': '#5f574a', '--p-tree-fill': '#333a28', '--p-tree-stroke': '#56633f',
|
||||
'--p-str-fill': '#3b362e', '--p-str-stroke': '#5f574a',
|
||||
};
|
||||
const P = {
|
||||
get() { try { return localStorage.getItem(KEY) || 'system'; } catch (e) { return 'system'; } },
|
||||
set(v) { try { localStorage.setItem(KEY, v); } catch (e) {} },
|
||||
sysDark() { return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); },
|
||||
isDark(pref) { return pref === 'dark' || (pref === 'system' && P.sysDark()); },
|
||||
apply(dark) {
|
||||
const r = document.documentElement.style;
|
||||
Object.keys(DARK).forEach(k => { dark ? r.setProperty(k, DARK[k]) : r.removeProperty(k); });
|
||||
r.colorScheme = dark ? 'dark' : 'light';
|
||||
},
|
||||
watch(cb) {
|
||||
if (!window.matchMedia) return () => {};
|
||||
const m = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
m.addEventListener('change', cb);
|
||||
return () => m.removeEventListener('change', cb);
|
||||
},
|
||||
next(p) { return p === 'system' ? 'light' : p === 'light' ? 'dark' : 'system'; },
|
||||
icon(p) {
|
||||
return {
|
||||
system: ['M4 3h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z', 'M8 21h8', 'M12 17v4'],
|
||||
light: ['M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8', 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4', ''],
|
||||
dark: ['M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z', '', ''],
|
||||
}[p] || ['', '', ''];
|
||||
},
|
||||
};
|
||||
window.PansyTheme = P;
|
||||
P.apply(P.isDark(P.get()));
|
||||
})();
|
||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -5,11 +5,9 @@ go 1.26.2
|
||||
require (
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/gen2brain/heic v0.7.1
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/samber/slog-gin v1.15.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
modernc.org/sqlite v1.34.4
|
||||
)
|
||||
@@ -35,7 +33,6 @@ require (
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
@@ -54,15 +51,14 @@ require (
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/tetratelabs/wazero v1.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
|
||||
@@ -26,16 +26,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||
github.com/gen2brain/heic v0.7.1 h1:Aha1sZdKEeZeWl5o0xkSg7NBRhhkrlokGVCRri+2Qcc=
|
||||
github.com/gen2brain/heic v0.7.1/go.mod h1:ja42wMJc4fpnKsfdUJxeZa2YqqRnes1wS0xqs5+8o5w=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
@@ -130,8 +126,6 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
@@ -150,13 +144,11 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -171,27 +163,27 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
@@ -39,23 +41,29 @@ type Runner struct {
|
||||
model llm.Model
|
||||
}
|
||||
|
||||
// NewRunner resolves modelSpec against pansy's registry and returns a Runner, or
|
||||
// an error if the assistant can't be offered. Callers should treat an error as
|
||||
// "no assistant" rather than a startup failure — an instance with no key must
|
||||
// still serve the app.
|
||||
//
|
||||
// It takes the key and spec explicitly rather than a *config.Config so the same
|
||||
// constructor serves both boot (from env) and a runtime settings change (from
|
||||
// the DB) — the Runner has no idea which one configured it.
|
||||
func NewRunner(svc *service.Service, apiKey, modelSpec string) (*Runner, error) {
|
||||
if apiKey == "" {
|
||||
// NewRunner resolves the configured model and returns a Runner, or an error if
|
||||
// the assistant can't be offered. Callers should treat an error as "no
|
||||
// assistant" rather than a startup failure — an instance with no key must still
|
||||
// serve the app.
|
||||
func NewRunner(svc *service.Service, cfg *config.Config) (*Runner, error) {
|
||||
if !cfg.Agent.Ready() {
|
||||
return nil, errors.New("agent: not configured")
|
||||
}
|
||||
// agentmodel.Resolve already rejects an empty/blank spec, so don't duplicate
|
||||
// that guard here — one place decides what a valid spec is.
|
||||
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
||||
|
||||
// A private registry, not the package-level default: pansy passes the key it
|
||||
// was configured with rather than depending on ambient environment, and
|
||||
// majordomo's own ollama-cloud preset reads OLLAMA_API_KEY while pansy (like
|
||||
// gadfly) is configured with OLLAMA_CLOUD_API_KEY. Registering the provider
|
||||
// explicitly makes that bridge visible instead of a mysterious empty token.
|
||||
reg := majordomo.New()
|
||||
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(cfg.Agent.OllamaCloudAPIKey)))
|
||||
|
||||
// The spec goes to Parse VERBATIM. The grammar — including comma-separated
|
||||
// failover chains — is majordomo's, and re-implementing any of it here would
|
||||
// only mean two places to update when it grows.
|
||||
model, err := reg.Parse(cfg.Agent.Model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("agent: resolve model %q: %w", cfg.Agent.Model, err)
|
||||
}
|
||||
return &Runner{svc: svc, model: model}, nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
@@ -60,7 +61,7 @@ func TestTurnIsOneChangeSet(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
|
||||
t.Fatalf("seed garlic: %v", err)
|
||||
}
|
||||
|
||||
@@ -231,24 +232,24 @@ func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRunnerNeedsConfiguration — an instance with no key or no model must not
|
||||
// get a half-built runner; the caller treats the error as "no assistant" and
|
||||
// carries on. (Whether the assistant is ENABLED is resolved before NewRunner is
|
||||
// reached, so it isn't NewRunner's concern any more.)
|
||||
// TestNewRunnerNeedsConfiguration — an instance with no key must not get a
|
||||
// half-built runner; the caller treats the error as "no assistant" and carries on.
|
||||
func TestNewRunnerNeedsConfiguration(t *testing.T) {
|
||||
svc, _ := newAgentTestService(t)
|
||||
for _, tc := range []struct{ key, model string }{
|
||||
{"", "ollama-cloud/x"},
|
||||
{"k", ""},
|
||||
{"k", " "},
|
||||
for _, cfg := range []*config.Config{
|
||||
{Agent: config.AgentConfig{Enabled: false, OllamaCloudAPIKey: "k", Model: "ollama-cloud/x"}},
|
||||
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "", Model: "ollama-cloud/x"}},
|
||||
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "k", Model: ""}},
|
||||
} {
|
||||
if _, err := NewRunner(svc, tc.key, tc.model); err == nil {
|
||||
t.Errorf("NewRunner accepted key=%q model=%q", tc.key, tc.model)
|
||||
if _, err := NewRunner(svc, cfg); err == nil {
|
||||
t.Errorf("NewRunner accepted %+v", cfg.Agent)
|
||||
}
|
||||
}
|
||||
// A model spec naming a provider that doesn't exist is a configuration
|
||||
// error, not a panic at first use.
|
||||
if _, err := NewRunner(svc, "k", "nonesuch/model"); err == nil {
|
||||
if _, err := NewRunner(svc, &config.Config{Agent: config.AgentConfig{
|
||||
Enabled: true, OllamaCloudAPIKey: "k", Model: "nonesuch/model",
|
||||
}}); err == nil {
|
||||
t.Error("NewRunner accepted an unresolvable model spec")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,40 +64,6 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
||||
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
|
||||
"thing is. observedAt defaults to today; set it to backdate.",
|
||||
a.addJournalEntry),
|
||||
llm.DefineTool("read_journal",
|
||||
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+
|
||||
"Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+
|
||||
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+
|
||||
"or \"what happened last spring?\".",
|
||||
a.readJournal),
|
||||
llm.DefineTool("update_object",
|
||||
"Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+
|
||||
"rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+
|
||||
"you pass change. Needs the object's current version from describe_garden. Example: "+
|
||||
"\"make that bed 60cm wider\" — read its widthCm from describe_garden, add 60, pass the "+
|
||||
"sum.",
|
||||
a.updateObject),
|
||||
llm.DefineTool("delete_object",
|
||||
"Delete an object from a garden entirely, along with its plantings. This is the "+
|
||||
"counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+
|
||||
"the same as clearing a bed's plants); prefer clear_object when the bed itself stays.",
|
||||
a.deleteObject),
|
||||
llm.DefineTool("remove_planting",
|
||||
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
|
||||
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
|
||||
"bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+
|
||||
"basil out of the corner\".",
|
||||
a.removePlanting),
|
||||
llm.DefineTool("list_seed_lots",
|
||||
"List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+
|
||||
"left — optionally for one plant via plantId. This is the detail behind the \"seed "+
|
||||
"remaining\" number find_plant reports.",
|
||||
a.listSeedLots),
|
||||
llm.DefineTool("record_seed_lot",
|
||||
"Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+
|
||||
"Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+
|
||||
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".",
|
||||
a.recordSeedLot),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,11 +127,8 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
|
||||
Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all"`
|
||||
PlantID int64 `json:"plantId" description:"plant to fill with"`
|
||||
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
|
||||
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
|
||||
}) (any, error) {
|
||||
// nil: the agent runs server-side with no local day, so the fill dates
|
||||
// plops UTC-today like its create_planting does.
|
||||
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode), nil)
|
||||
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride)
|
||||
}
|
||||
|
||||
func (a *adapter) findPlant(ctx context.Context, args struct {
|
||||
@@ -213,80 +176,3 @@ func (a *adapter) clearObject(ctx context.Context, args struct {
|
||||
}
|
||||
return map[string]int{"cleared": n}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) readJournal(ctx context.Context, args struct {
|
||||
GardenID int64 `json:"gardenId" description:"garden whose journal to read"`
|
||||
ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"`
|
||||
From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"`
|
||||
To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"`
|
||||
Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"`
|
||||
}) (any, error) {
|
||||
q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50, Offset: args.Offset}
|
||||
if args.From != "" {
|
||||
q.From = &args.From
|
||||
}
|
||||
if args.To != "" {
|
||||
q.To = &args.To
|
||||
}
|
||||
entries, hasMore, err := a.svc.ListJournal(ctx, a.actor, args.GardenID, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// hasMore is actionable now: re-call with offset += len(entries) to page.
|
||||
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) updateObject(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object to change"`
|
||||
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
|
||||
Name *string `json:"name" description:"optional new label"`
|
||||
WidthCM *float64 `json:"widthCm" description:"optional new width in cm (a circle's diameter)"`
|
||||
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
|
||||
RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"`
|
||||
Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"`
|
||||
}) (any, error) {
|
||||
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{
|
||||
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
|
||||
RotationDeg: args.RotationDeg, Plantable: args.Plantable,
|
||||
}, args.Version)
|
||||
}
|
||||
|
||||
func (a *adapter) deleteObject(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"`
|
||||
}) (any, error) {
|
||||
if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"deleted": args.ObjectID}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) removePlanting(ctx context.Context, args struct {
|
||||
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
|
||||
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
|
||||
}) (any, error) {
|
||||
// Soft-remove via the service, so removed_at is stamped from the same
|
||||
// (injectable) clock clear_object uses rather than the adapter's wall clock.
|
||||
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version)
|
||||
}
|
||||
|
||||
func (a *adapter) listSeedLots(ctx context.Context, args struct {
|
||||
PlantID *int64 `json:"plantId" description:"optional: only lots for this plant"`
|
||||
}) (any, error) {
|
||||
return a.svc.ListSeedLots(ctx, a.actor, args.PlantID)
|
||||
}
|
||||
|
||||
func (a *adapter) recordSeedLot(ctx context.Context, args struct {
|
||||
PlantID int64 `json:"plantId" description:"plant the seed is for (from find_plant); must be the user's own or a built-in"`
|
||||
Quantity float64 `json:"quantity" description:"how much was bought, in the given unit"`
|
||||
Unit string `json:"unit" description:"what quantity counts, e.g. packets | seeds | grams"`
|
||||
Vendor string `json:"vendor" description:"optional vendor name"`
|
||||
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where it was bought"`
|
||||
PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"`
|
||||
Notes string `json:"notes" description:"optional free-text notes"`
|
||||
}) (any, error) {
|
||||
return a.svc.CreateSeedLot(ctx, a.actor, service.SeedLotInput{
|
||||
PlantID: args.PlantID, Quantity: args.Quantity, Unit: args.Unit,
|
||||
Vendor: args.Vendor, SourceURL: args.SourceURL,
|
||||
PackedForYear: args.PackedForYear, Notes: args.Notes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestGarlicBedToCucumbers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
|
||||
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
|
||||
t.Fatalf("seed the garlic: %v", err)
|
||||
}
|
||||
|
||||
@@ -347,127 +347,6 @@ func TestJournalToolWritesADatedObservation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectiveTools covers the #85 gaps: the agent can now read the journal it
|
||||
// could only write, resize and delete an object it could only create and move,
|
||||
// pull a single plop instead of clearing the whole bed, and record/read seed
|
||||
// lots. Each is driven through the tool layer the way a model would run it.
|
||||
func TestCorrectiveTools(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, owner := newAgentTestService(t)
|
||||
box := NewToolbox(svc, owner)
|
||||
|
||||
var gid int64 // set once the garden exists; the describe closure reads it.
|
||||
call := func(name string, args any) llm.ToolResult {
|
||||
t.Helper()
|
||||
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
|
||||
}
|
||||
describe := func() service.DescribeResult {
|
||||
t.Helper()
|
||||
res := call("describe_garden", map[string]any{"gardenId": gid})
|
||||
if res.IsError {
|
||||
t.Fatalf("describe_garden: %s", res.Content)
|
||||
}
|
||||
var d service.DescribeResult
|
||||
if err := json.Unmarshal([]byte(res.Content), &d); err != nil {
|
||||
t.Fatalf("decode describe: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
gid = g.ID
|
||||
basil := mustPlant(t, svc, owner, "Basil", 25, "🌿")
|
||||
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
|
||||
// update_object: "make that bed 100cm wider" — read the version, pass a new width.
|
||||
d := describe()
|
||||
if r := call("update_object", map[string]any{
|
||||
"objectId": bed.ID, "version": d.Objects[0].Version, "widthCm": 500.0,
|
||||
}); r.IsError {
|
||||
t.Fatalf("update_object: %s", r.Content)
|
||||
}
|
||||
if w := describe().Objects[0].WidthCM; w != 500 {
|
||||
t.Errorf("width = %v after update_object, want 500", w)
|
||||
}
|
||||
|
||||
// place a plop, then remove_planting it by id+version — one plop, not the bed.
|
||||
if r := call("place_planting", map[string]any{
|
||||
"objectId": bed.ID, "plantId": basil.ID, "xCm": 0, "yCm": 0, "radiusCm": 30,
|
||||
}); r.IsError {
|
||||
t.Fatalf("place_planting: %s", r.Content)
|
||||
}
|
||||
d = describe()
|
||||
if len(d.Objects[0].Plantings) != 1 {
|
||||
t.Fatalf("want 1 plop before removal, got %d", len(d.Objects[0].Plantings))
|
||||
}
|
||||
plop := d.Objects[0].Plantings[0]
|
||||
if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError {
|
||||
t.Fatalf("remove_planting: %s", r.Content)
|
||||
}
|
||||
if n := len(describe().Objects[0].Plantings); n != 0 {
|
||||
t.Errorf("want 0 active plops after remove_planting, got %d", n)
|
||||
}
|
||||
|
||||
// add then read the journal — the write/read asymmetry the issue flagged.
|
||||
if r := call("add_journal_entry", map[string]any{
|
||||
"gardenId": g.ID, "objectId": bed.ID, "body": "aphids", "observedAt": "2026-06-01",
|
||||
}); r.IsError {
|
||||
t.Fatalf("add_journal_entry: %s", r.Content)
|
||||
}
|
||||
res := call("read_journal", map[string]any{"gardenId": g.ID, "objectId": bed.ID})
|
||||
if res.IsError {
|
||||
t.Fatalf("read_journal: %s", res.Content)
|
||||
}
|
||||
var jr struct {
|
||||
Entries []struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.Content), &jr); err != nil {
|
||||
t.Fatalf("decode read_journal: %v (%s)", err, res.Content)
|
||||
}
|
||||
if len(jr.Entries) != 1 || jr.Entries[0].Body != "aphids" {
|
||||
t.Errorf("read_journal = %+v, want the one aphids entry", jr.Entries)
|
||||
}
|
||||
|
||||
// record then list a seed lot — the detail behind find_plant's "remaining".
|
||||
if r := call("record_seed_lot", map[string]any{
|
||||
"plantId": basil.ID, "quantity": 2.0, "unit": "packets", "vendor": "Johnny's",
|
||||
}); r.IsError {
|
||||
t.Fatalf("record_seed_lot: %s", r.Content)
|
||||
}
|
||||
res = call("list_seed_lots", map[string]any{"plantId": basil.ID})
|
||||
if res.IsError {
|
||||
t.Fatalf("list_seed_lots: %s", res.Content)
|
||||
}
|
||||
var lots []struct {
|
||||
Quantity float64 `json:"quantity"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.Content), &lots); err != nil {
|
||||
t.Fatalf("decode list_seed_lots: %v (%s)", err, res.Content)
|
||||
}
|
||||
if len(lots) != 1 || lots[0].Quantity != 2 || lots[0].Unit != "packets" {
|
||||
t.Errorf("list_seed_lots = %+v, want one lot of 2 packets", lots)
|
||||
}
|
||||
|
||||
// delete_object: the counterpart to create_object.
|
||||
if r := call("delete_object", map[string]any{"objectId": bed.ID}); r.IsError {
|
||||
t.Fatalf("delete_object: %s", r.Content)
|
||||
}
|
||||
if n := len(describe().Objects); n != 0 {
|
||||
t.Errorf("want 0 objects after delete_object, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// newAgentTestService spins up an in-memory pansy with one registered user.
|
||||
func newAgentTestService(t *testing.T) (*service.Service, int64) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Package agentmodel holds the ONE place that knows how pansy turns a model spec
|
||||
// into a majordomo model: which provider to register and under which token.
|
||||
//
|
||||
// It exists as a leaf so both internal/agent (which builds the run loop) and
|
||||
// internal/service (which validates a spec before storing it as a setting) can
|
||||
// share that knowledge without an import cycle — agent imports service, so the
|
||||
// shared bit can live in neither of them.
|
||||
package agentmodel
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
||||
)
|
||||
|
||||
// registry builds the private majordomo registry pansy uses.
|
||||
//
|
||||
// Private, not the package-level default: pansy passes the key it was configured
|
||||
// with rather than depending on ambient environment, and majordomo's own
|
||||
// ollama-cloud preset reads OLLAMA_API_KEY while pansy (like gadfly) is
|
||||
// configured with OLLAMA_CLOUD_API_KEY. Registering the provider explicitly
|
||||
// makes that bridge visible instead of a mysterious empty token.
|
||||
func registry(apiKey string) *majordomo.Registry {
|
||||
reg := majordomo.New()
|
||||
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(apiKey)))
|
||||
return reg
|
||||
}
|
||||
|
||||
// Resolve parses a model spec against pansy's registry into a live model. The
|
||||
// spec goes to Parse VERBATIM — the grammar, including comma-separated failover
|
||||
// chains, is majordomo's, and re-implementing any of it here would only mean two
|
||||
// places to update when it grows.
|
||||
func Resolve(apiKey, spec string) (llm.Model, error) {
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
return nil, errors.New("agentmodel: empty model spec")
|
||||
}
|
||||
m, err := registry(apiKey).Parse(spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agentmodel: resolve %q: %w", spec, err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Validate reports whether a spec resolves, without building anything the caller
|
||||
// keeps — the cheap, deterministic check a settings save runs to reject a typo.
|
||||
//
|
||||
// It does NOT make a live call, so it needs no working key and won't catch a
|
||||
// model that is merely absent upstream; that surfaces on first use. Parse
|
||||
// resolving (known provider, well-formed spec) is the half worth doing eagerly.
|
||||
func Validate(apiKey, spec string) error {
|
||||
_, err := Resolve(apiKey, spec)
|
||||
return err
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package agentmodel
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestValidate is what the settings PATCH relies on to reject a typo at save
|
||||
// time rather than on the next chat turn.
|
||||
func TestValidate(t *testing.T) {
|
||||
if err := Validate("k", "ollama-cloud/glm-5.2:cloud"); err != nil {
|
||||
t.Errorf("valid spec rejected: %v", err)
|
||||
}
|
||||
// No key needed to validate — Parse resolves the provider, it doesn't call it.
|
||||
if err := Validate("", "ollama-cloud/glm-5.2:cloud"); err != nil {
|
||||
t.Errorf("valid spec rejected without a key: %v", err)
|
||||
}
|
||||
// A comma-separated failover chain is majordomo grammar and must resolve.
|
||||
if err := Validate("k", "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud"); err != nil {
|
||||
t.Errorf("failover chain rejected: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", " ", "nonesuch/model"} {
|
||||
if err := Validate("k", bad); err == nil {
|
||||
t.Errorf("Validate accepted %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveReturnsAModel confirms a good spec yields a usable model handle.
|
||||
func TestResolveReturnsAModel(t *testing.T) {
|
||||
m, err := Resolve("k", "ollama-cloud/glm-5.2:cloud")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if m == nil {
|
||||
t.Fatal("resolve returned a nil model with no error")
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
@@ -26,11 +24,6 @@ import (
|
||||
// by everything at once, which reads as a hang — and the whole design rests on
|
||||
// watching the canvas change as it happens.
|
||||
|
||||
// keepAliveInterval is how often a quiet stream emits a comment frame. Well
|
||||
// under the 30–60s idle timeout typical of reverse proxies, which is the thing
|
||||
// it exists to stay ahead of.
|
||||
const keepAliveInterval = 20 * time.Second
|
||||
|
||||
// chatRequest is the body of POST /agent/chat.
|
||||
type chatRequest struct {
|
||||
GardenID int64 `json:"gardenId" binding:"required"`
|
||||
@@ -56,16 +49,6 @@ type stepEvent struct {
|
||||
}
|
||||
|
||||
func (h *handlers) agentChat(c *gin.Context) {
|
||||
// The route is always registered, so the assistant being off is a runtime
|
||||
// state, not a missing route: answer it plainly rather than 404ing a path
|
||||
// that exists. Loaded once here so a settings-driven swap mid-request can't
|
||||
// make it flip between the guard and the Run call.
|
||||
runner := h.agent.get()
|
||||
if runner == nil {
|
||||
writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance")
|
||||
return
|
||||
}
|
||||
|
||||
var req chatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
|
||||
@@ -79,21 +62,13 @@ func (h *handlers) agentChat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
stream := openEventStream(c)
|
||||
send := stream.send
|
||||
send := openEventStream(c)
|
||||
|
||||
// A model thinking hard between tool calls sends nothing for a while, and an
|
||||
// idle proxy will cut a quiet connection. Deferred so a panic in the run
|
||||
// can't leak the ticker goroutine; stopping it twice is harmless.
|
||||
stopBeat := stream.keepAlive(keepAliveInterval)
|
||||
defer stopBeat()
|
||||
|
||||
turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||
replayHistory(history),
|
||||
func(s mdagent.Step) {
|
||||
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
|
||||
})
|
||||
stopBeat()
|
||||
if err != nil {
|
||||
// The stream is already open, so an error is an event rather than a
|
||||
// status code — the client has committed to reading a stream by now.
|
||||
@@ -118,117 +93,25 @@ func (h *handlers) agentChat(c *gin.Context) {
|
||||
send(chatEvent{Done: turn})
|
||||
}
|
||||
|
||||
// sseWriteTimeout bounds ONE write to the stream, not the stream itself.
|
||||
//
|
||||
// It is refreshed per frame, which is the only shape that satisfies both ends:
|
||||
// the server's absolute WriteTimeout would cut a long turn (#78), while removing
|
||||
// the deadline entirely would let a client that stops reading block a write
|
||||
// forever once the socket buffer fills — pinning the run goroutine and this
|
||||
// stream's mutex with it, and taking the keep-alive down too since it needs the
|
||||
// same lock. Generous, because it is a backstop against a stuck peer and not a
|
||||
// pacing mechanism.
|
||||
//
|
||||
// A var, not a const, ONLY so the test can shrink it to prove the deadline is
|
||||
// refreshed per frame rather than set once — a set-once 30s deadline would pass
|
||||
// a test whose whole run is under a second. Production never reassigns it.
|
||||
var sseWriteTimeout = 30 * time.Second
|
||||
|
||||
// eventStream serializes writes to one SSE response.
|
||||
//
|
||||
// The mutex is load-bearing, not decoration: step events are sent from the
|
||||
// agent's run goroutine while the keep-alive ticker writes from its own, and two
|
||||
// goroutines writing a ResponseWriter concurrently is a data race that corrupts
|
||||
// frames long before it crashes anything.
|
||||
type eventStream struct {
|
||||
c *gin.Context
|
||||
rc *http.ResponseController
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// openEventStream puts the response into SSE mode.
|
||||
// openEventStream puts the response into SSE mode and returns a sender.
|
||||
//
|
||||
// Headers go out before the first write and the stream is flushed immediately,
|
||||
// so a proxy holding the response until it looks complete can't reintroduce
|
||||
// exactly the silence streaming exists to remove.
|
||||
//
|
||||
// Taking the write deadline off the server's absolute WriteTimeout and onto a
|
||||
// per-write one is what makes a turn longer than 30s possible at all (#78).
|
||||
// WriteTimeout is an ABSOLUTE deadline from when the request header was read,
|
||||
// not an idle timeout, so a streaming response is cut mid-turn however recently
|
||||
// it wrote. Without this the 4-minute runTimeout is unreachable and the
|
||||
// keep-alive below tops out at one tick — pacing a connection that is destroyed
|
||||
// underneath it.
|
||||
//
|
||||
// That failure is INVISIBLE from in here: writes past the deadline return
|
||||
// err == nil and their bytes are dropped, so there is nothing to detect on the
|
||||
// write path. Only the client sees it, as a truncated stream it reports as a
|
||||
// dropped connection. Hence a deadline set up front and refreshed per frame,
|
||||
// rather than anything checked after the fact.
|
||||
func openEventStream(c *gin.Context) *eventStream {
|
||||
func openEventStream(c *gin.Context) func(chatEvent) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
s := &eventStream{c: c, rc: http.NewResponseController(c.Writer)}
|
||||
// Probe once here rather than reporting per frame: a writer that can't take
|
||||
// deadlines will fail identically on every write, and the operator needs to
|
||||
// hear it once. If this fails the stream still works — it is just back to
|
||||
// being cut at WriteTimeout, which is worth saying out loud.
|
||||
if err := s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout)); err != nil {
|
||||
slog.Error("api: SSE write deadlines unavailable; long turns will be truncated at the server WriteTimeout", "error", err)
|
||||
}
|
||||
c.Writer.Flush()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *eventStream) send(ev chatEvent) {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
slog.Error("api: encode chat event", "error", err)
|
||||
return
|
||||
}
|
||||
s.write(fmt.Sprintf("data: %s\n\n", b))
|
||||
}
|
||||
|
||||
func (s *eventStream) write(frame string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Refresh for THIS write, so the stream as a whole is unbounded but no single
|
||||
// write is. Error deliberately unchecked: openEventStream already reported
|
||||
// whether deadlines work at all, and this call can only fail the same way, so
|
||||
// checking here would log once per frame to say the same thing.
|
||||
_ = s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout))
|
||||
_, _ = io.WriteString(s.c.Writer, frame)
|
||||
s.c.Writer.Flush()
|
||||
}
|
||||
|
||||
// keepAlive writes an SSE comment frame on an interval until the returned
|
||||
// function is called, so a long silence while the model thinks doesn't look like
|
||||
// a dead connection to whatever sits in between. SSE ignores comment frames, so
|
||||
// this costs the client nothing.
|
||||
func (s *eventStream) keepAlive(every time.Duration) func() {
|
||||
done := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-s.c.Request.Context().Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.write(": keep-alive\n\n")
|
||||
}
|
||||
return func(ev chatEvent) {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
slog.Error("api: encode chat event", "error", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
// Idempotent: the handler stops it explicitly when the run returns and again
|
||||
// via defer, so a panic can't leak the goroutine.
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() { close(done) })
|
||||
<-stopped
|
||||
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", b)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// agentHolder owns the live assistant Runner and lets an admin swap it at
|
||||
// runtime when the model settings change (#79).
|
||||
//
|
||||
// The Runner used to be a plain handlers field set once at boot, with the chat
|
||||
// routes registered only when it existed. That made the assistant permanently
|
||||
// whatever the environment said at startup. Now the routes are always
|
||||
// registered and the Runner lives behind an atomic pointer, so a settings change
|
||||
// can turn the assistant on, off, or onto a different model without a restart —
|
||||
// and without a data race against in-flight requests reading the pointer.
|
||||
//
|
||||
// A nil pointer means "no assistant right now"; handlers nil-check get() rather
|
||||
// than assuming a Runner is present.
|
||||
type agentHolder struct {
|
||||
svc *service.Service
|
||||
|
||||
ptr atomic.Pointer[agent.Runner]
|
||||
// rebuildMu serializes rebuilds so two concurrent settings saves can't
|
||||
// interleave into a torn "resolve A, resolve B, store A, store B" swap. The
|
||||
// read path (get) stays lock-free on the atomic pointer.
|
||||
rebuildMu sync.Mutex
|
||||
}
|
||||
|
||||
// newAgentHolder builds the holder and resolves the initial Runner from whatever
|
||||
// the settings + environment currently say. A resolution failure is logged and
|
||||
// left as "no assistant", never fatal: a garden planner must still boot.
|
||||
func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder {
|
||||
h := &agentHolder{svc: svc}
|
||||
h.rebuild(ctx)
|
||||
return h
|
||||
}
|
||||
|
||||
// get returns the current Runner, or nil if the assistant is off.
|
||||
func (h *agentHolder) get() *agent.Runner { return h.ptr.Load() }
|
||||
|
||||
// rebuild resolves the effective agent configuration and swaps the Runner to
|
||||
// match: a new one when the assistant should be on, nil when it shouldn't. It is
|
||||
// safe to call at boot and from a settings save; concurrent calls serialize.
|
||||
//
|
||||
// It logs what it did rather than returning an error, because every caller wants
|
||||
// the same thing — best-effort apply, keep serving either way — and a settings
|
||||
// save must not fail just because the new model won't resolve. The save already
|
||||
// validated the spec; a rebuild failure here means the environment changed under
|
||||
// it, and "assistant off, with a reason in the log" is the right outcome.
|
||||
func (h *agentHolder) rebuild(ctx context.Context) {
|
||||
h.rebuildMu.Lock()
|
||||
defer h.rebuildMu.Unlock()
|
||||
|
||||
eff, err := h.svc.EffectiveAgent(ctx)
|
||||
if err != nil {
|
||||
// EffectiveAgent errors only if the settings row can't be read — a DB fault,
|
||||
// and a very transient one when it happens right after a settings write. We
|
||||
// keep the current Runner rather than tear down a working assistant on a
|
||||
// blip: the state is already persisted, so the next rebuild (any later save,
|
||||
// or a restart) reconciles it. Loud, because a persistent failure here means
|
||||
// the live assistant no longer matches stored settings.
|
||||
slog.Error("api: could not resolve agent settings; leaving the assistant as-is", "error", err)
|
||||
return
|
||||
}
|
||||
if !eff.Ready() {
|
||||
if h.ptr.Swap(nil) != nil {
|
||||
slog.Info("api: garden assistant turned off",
|
||||
"enabled", eff.Enabled, "hasKey", eff.APIKey != "", "hasModel", eff.Model != "")
|
||||
}
|
||||
return
|
||||
}
|
||||
runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model)
|
||||
if err != nil {
|
||||
// Configured but unusable. Turn the assistant off rather than leaving a
|
||||
// stale Runner on the old model — an admin who just pointed it at a broken
|
||||
// spec should see it stop, not silently keep answering on the previous one.
|
||||
slog.Error("api: garden assistant disabled (model won't resolve)", "error", err, "model", eff.Model)
|
||||
h.ptr.Store(nil)
|
||||
return
|
||||
}
|
||||
h.ptr.Store(runner)
|
||||
slog.Info("api: garden assistant ready", "model", eff.Model)
|
||||
}
|
||||
@@ -6,40 +6,24 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAgentDisabledWithoutAKey — an instance with no API key must start, serve
|
||||
// the app, and not offer the assistant.
|
||||
//
|
||||
// The contract CHANGED with #79: the chat route is now always registered (so a
|
||||
// settings change can turn the assistant on without a restart), so "off" is a
|
||||
// runtime 503 rather than a missing route. capabilities reports agent:false, and
|
||||
// the frontend keys the chat tab off that — so a user never reaches the 503.
|
||||
func TestAgentDisabledWithoutAKey(t *testing.T) {
|
||||
// TestAgentRoutesAbsentWithoutAKey — an instance with no API key must start,
|
||||
// serve the app, and simply not offer the assistant. The routes aren't
|
||||
// registered at all, so this is a 404 rather than a handler that apologizes:
|
||||
// the same shape as OIDC when unconfigured.
|
||||
func TestAgentRoutesAbsentWithoutAKey(t *testing.T) {
|
||||
r := authEngine(t, localCfg()) // localCfg has no agent configuration
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
gid := createGardenAPI(t, r, cookie, "G")
|
||||
|
||||
// Chat is refused, plainly, because there is no Runner to run.
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
||||
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("chat without a key: status %d, want 503", w.Code)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("chat without a key: status %d, want 404", w.Code)
|
||||
}
|
||||
|
||||
// Capabilities advertises the assistant as unavailable, which is what the UI
|
||||
// actually consults.
|
||||
w = doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("capabilities: status %d", w.Code)
|
||||
}
|
||||
if agent, _ := decodeMap(t, w.Body.Bytes())["agent"].(bool); agent {
|
||||
t.Error("capabilities reported agent:true with no key")
|
||||
}
|
||||
|
||||
// History is just stored data behind the ordinary garden-role check, so it
|
||||
// reads fine (empty) whether or not a Runner exists — it isn't gated on one.
|
||||
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
|
||||
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusOK {
|
||||
t.Errorf("history without a key: status %d, want 200 (it's data, not the model)", w.Code)
|
||||
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusNotFound {
|
||||
t.Errorf("history without a key: status %d, want 404", w.Code)
|
||||
}
|
||||
|
||||
// And the rest of the app is entirely unaffected.
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
sloggin "github.com/samber/slog-gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
@@ -23,12 +23,9 @@ type handlers struct {
|
||||
cfg *config.Config
|
||||
svc *service.Service
|
||||
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
|
||||
// agent holds the live Runner behind an atomic pointer. Unlike oidc it is
|
||||
// never nil — the holder is always present and its Runner may be nil when the
|
||||
// assistant is off. The chat routes are registered unconditionally and
|
||||
// nil-check agent.get(), so a settings change can turn the assistant on or off
|
||||
// at runtime (#79) rather than only at boot.
|
||||
agent *agentHolder
|
||||
// agent is nil unless the assistant is configured; the chat routes are only
|
||||
// registered when it isn't, so a handler never has to check.
|
||||
agent *agent.Runner
|
||||
}
|
||||
|
||||
// New builds the gin engine with the standard middleware stack and registers the
|
||||
@@ -56,10 +53,12 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
// is set; see csrfGuard).
|
||||
v1.Use(h.csrfGuard())
|
||||
v1.GET("/healthz", healthz)
|
||||
// What this instance can actually do, so the UI offers only what works.
|
||||
// Registered after the agent below, because it reports whether the runner
|
||||
// actually built — not merely whether it was configured to.
|
||||
v1.GET("/capabilities", h.capabilities)
|
||||
// What this instance can actually do, so the UI offers only what works. The
|
||||
// agent routes 404 when unconfigured; without this the client would have to
|
||||
// probe for a 404 to find that out, and a dead button is worse than no button.
|
||||
v1.GET("/capabilities", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"agent": cfg.Agent.Ready()})
|
||||
})
|
||||
|
||||
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
|
||||
// /me is the one that needs a session. Feature routers in later issues attach
|
||||
@@ -122,11 +121,6 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
objects.PATCH("/:id", h.updateObject)
|
||||
objects.DELETE("/:id", h.deleteObject)
|
||||
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
|
||||
// Bulk ops. These wrap the same service methods the agent tools call, so an
|
||||
// instance with no model configured still gets the most valuable operation in
|
||||
// the app — and so "clear bed" is ONE change set rather than one per plop.
|
||||
objects.POST("/:id/fill", h.fillObject)
|
||||
objects.POST("/:id/clear", h.clearObject)
|
||||
|
||||
// Plantings ("plops") are addressed by their own id; the service resolves the
|
||||
// owning object/garden for the permission check.
|
||||
@@ -134,30 +128,26 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
plantings.PATCH("/:id", h.updatePlanting)
|
||||
plantings.DELETE("/:id", h.deletePlanting)
|
||||
|
||||
// The garden assistant. Its routes are registered UNCONDITIONALLY and the live
|
||||
// Runner sits behind an atomic pointer in the holder, so a settings change can
|
||||
// turn the assistant on or off at runtime (#79). Each handler nil-checks
|
||||
// agent.get(); a chat request while the assistant is off gets a clean 503
|
||||
// (AGENT_DISABLED), not a panic and not a missing route.
|
||||
//
|
||||
// The holder resolves its initial Runner from settings + environment at
|
||||
// construction. If the key never reaches the container, the assistant is off
|
||||
// and the reason is logged below — the same operability need #72 added.
|
||||
h.agent = newAgentHolder(context.Background(), svc)
|
||||
if cfg.Agent.OllamaCloudAPIKey == "" {
|
||||
slog.Info("api: garden assistant has no API key",
|
||||
"hint", "set OLLAMA_CLOUD_API_KEY in the container's environment (not just the stack's); the model can be chosen in Settings")
|
||||
// The garden assistant, registered only when it can actually be offered —
|
||||
// the same shape as OIDC. An instance with no API key serves the app
|
||||
// normally and simply doesn't have these routes.
|
||||
if cfg.Agent.Ready() {
|
||||
runner, err := agent.NewRunner(svc, cfg)
|
||||
if err != nil {
|
||||
// Configured but unusable (an unresolvable model spec, say). Log it and
|
||||
// carry on without the assistant rather than refusing to start: a
|
||||
// garden planner that won't boot because of a chat feature is worse
|
||||
// than one without chat.
|
||||
slog.Error("api: garden assistant disabled", "error", err)
|
||||
} else {
|
||||
h.agent = runner
|
||||
agentGroup := v1.Group("/agent", h.requireAuth())
|
||||
agentGroup.POST("/chat", h.agentChat)
|
||||
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||
slog.Info("api: garden assistant enabled", "model", cfg.Agent.Model)
|
||||
}
|
||||
}
|
||||
agentGroup := v1.Group("/agent", h.requireAuth())
|
||||
agentGroup.POST("/chat", h.agentChat)
|
||||
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||
|
||||
// Instance settings: admin-only, and the first thing to enforce is_admin.
|
||||
// requireAdmin runs after requireAuth (it reads the actor requireAuth stored).
|
||||
settings := v1.Group("/settings", h.requireAuth(), h.requireAdmin())
|
||||
settings.GET("", h.getSettings)
|
||||
settings.PATCH("", h.updateSettings)
|
||||
|
||||
// Undo. A change set is addressed by its own id; the service resolves the
|
||||
// owning garden for the permission check, same as objects and plantings.
|
||||
@@ -185,10 +175,6 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
seedLots.GET("/:id", h.getSeedLot)
|
||||
seedLots.PATCH("/:id", h.updateSeedLot)
|
||||
seedLots.DELETE("/:id", h.deleteSeedLot)
|
||||
// Seed-packet capture (#81): scan a photo into a proposal, then create the
|
||||
// plant + lot from the confirmed proposal. scan reads only.
|
||||
seedLots.POST("/scan", h.scanSeedPacket)
|
||||
seedLots.POST("/from-packet", h.createFromPacket)
|
||||
|
||||
// Public, unauthenticated read of a garden by its share token. Deliberately
|
||||
// NOT behind requireAuth: the token is the capability, so a logged-out visitor
|
||||
@@ -200,29 +186,6 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
return r
|
||||
}
|
||||
|
||||
// capabilities reports what this instance can actually do, so the UI offers only
|
||||
// what works.
|
||||
//
|
||||
// It reports whether the assistant is live RIGHT NOW, not merely configured:
|
||||
// the chat routes always exist, but a request while the Runner is nil is refused,
|
||||
// so offering the tab must track the live Runner. Reading agent.get() (an atomic
|
||||
// load) means this reflects a settings-driven swap on the very next poll.
|
||||
func (h *handlers) capabilities(c *gin.Context) {
|
||||
// vision advertises whether seed-packet scanning (#81) can be offered — a
|
||||
// configured, resolvable vision model + a key. Read per-request so a settings
|
||||
// change is reflected on the next poll, same as agent.
|
||||
vision := false
|
||||
if vis, err := h.svc.EffectiveVision(c.Request.Context()); err != nil {
|
||||
// A read fault here means the DB is unhappy; report vision off (safe: the
|
||||
// UI just hides a button) but don't do it silently — the same best-effort
|
||||
// settings reads elsewhere log rather than swallow.
|
||||
slog.Error("api: could not resolve vision settings for capabilities", "error", err)
|
||||
} else {
|
||||
vision = vis.Ready()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil, "vision": vision})
|
||||
}
|
||||
|
||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||
func healthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -53,7 +52,7 @@ func writeServiceError(c *gin.Context, err error) {
|
||||
case errors.Is(err, domain.ErrOIDCIdentityConflict):
|
||||
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
|
||||
case errors.Is(err, domain.ErrInvalidInput):
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", inputMessage(err))
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input")
|
||||
default:
|
||||
slog.Error("api: unhandled service error", "error", err)
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
|
||||
@@ -115,18 +114,3 @@ func parseIDParam(c *gin.Context, name string) (int64, bool) {
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// inputMessage is the text a 400 carries for an ErrInvalidInput. The bare
|
||||
// sentinel reads "invalid input"; a service that wraps it with a reason —
|
||||
// fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)
|
||||
// — has that reason shown to the person verbatim, minus the sentinel prefix.
|
||||
// So anything wrapped this way is written for the keyboard, not the log (see
|
||||
// the note on domain.ErrInvalidInput).
|
||||
func inputMessage(err error) string {
|
||||
msg := err.Error()
|
||||
base := domain.ErrInvalidInput.Error()
|
||||
if msg == base {
|
||||
return msg
|
||||
}
|
||||
return strings.TrimPrefix(msg, base+": ")
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// TestInputMessage: the bare sentinel stays generic; a wrapped reason reaches
|
||||
// the person without the "invalid input: " prefix in front of it.
|
||||
func TestInputMessage(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{domain.ErrInvalidInput, "invalid input"},
|
||||
{fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, "nonesuch/model"), `chat model "nonesuch/model": unknown provider`},
|
||||
{fmt.Errorf("loading: %w", domain.ErrInvalidInput), "loading: invalid input"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if !errors.Is(c.err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("%v should still be an ErrInvalidInput", c.err)
|
||||
}
|
||||
if got := inputMessage(c.err); got != c.want {
|
||||
t.Errorf("inputMessage(%v) = %q, want %q", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// Bulk operations on a plantable object (#82): fill a region with one plant, and
|
||||
// clear everything out of it.
|
||||
//
|
||||
// These were reachable only through the agent toolbox until now, which meant the
|
||||
// most valuable bulk operation in a garden planner — and the one carrying the
|
||||
// most carefully reasoned geometry in the codebase — did not exist at all on an
|
||||
// instance with no model configured. They are thin adapters over the same
|
||||
// service methods `internal/agent/tools.go` calls, so the permission checks and
|
||||
// the one-change-set-per-operation guarantee come along unchanged.
|
||||
|
||||
// fillRect is an explicit rectangle in the object's local frame, the alternative
|
||||
// to a compass name. A named type (not an inline anonymous struct) to match the
|
||||
// rest of internal/api and so it can carry its own validity check.
|
||||
type fillRect struct {
|
||||
MinX float64 `json:"minXCm"`
|
||||
MinY float64 `json:"minYCm"`
|
||||
MaxX float64 `json:"maxXCm"`
|
||||
MaxY float64 `json:"maxYCm"`
|
||||
}
|
||||
|
||||
// degenerate reports whether the rect encloses no area. Such a rect (including
|
||||
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
|
||||
// and plant a single plop at the object's centre — a surprising result for what
|
||||
// is really malformed input.
|
||||
func (r fillRect) degenerate() bool {
|
||||
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
|
||||
}
|
||||
|
||||
// objectFillRequest is the body for POST /objects/:id/fill.
|
||||
//
|
||||
// A region is given EITHER by compass name ("ne", "south half", "all") or as an
|
||||
// explicit rect in the object's local frame. The named form is what a person
|
||||
// means and what the agent uses; the rect is for a future drag-a-box affordance.
|
||||
// Exactly one must be supplied — accepting both and silently preferring one
|
||||
// would make a client bug look like a geometry bug.
|
||||
type objectFillRequest struct {
|
||||
PlantID int64 `json:"plantId" binding:"required"`
|
||||
Region string `json:"region"`
|
||||
Rect *fillRect `json:"rect"`
|
||||
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
|
||||
// without editing the catalog entry.
|
||||
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
|
||||
// Layout is "clump" (default; fat clumps for a quick sketch) or "grid"
|
||||
// (individual plants in rows at true spacing). Empty = clump. An unknown value
|
||||
// is refused by the service (#77).
|
||||
Layout string `json:"layout"`
|
||||
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). The UI sends its
|
||||
// local day; omitted, the server uses UTC today — which is tomorrow for an
|
||||
// evening gardener west of Greenwich, so clients that know better say so.
|
||||
PlantedAt *string `json:"plantedAt"`
|
||||
}
|
||||
|
||||
func (h *handlers) fillObject(c *gin.Context) {
|
||||
id, ok := parseIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req objectFillRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
|
||||
return
|
||||
}
|
||||
named, hasRect := req.Region != "", req.Rect != nil
|
||||
if named == hasRect {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT",
|
||||
`supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`)
|
||||
return
|
||||
}
|
||||
|
||||
actor := mustActor(c).ID
|
||||
var (
|
||||
created []domain.Planting
|
||||
err error
|
||||
)
|
||||
if rect := req.Rect; rect != nil {
|
||||
// Reject a zero-area rect here rather than let it plant one stray plop.
|
||||
// (Binding the pointer to `rect` also keeps the deref visibly guarded,
|
||||
// instead of reading req.Rect.MinX under an invariant from a line above.)
|
||||
if rect.degenerate() {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
|
||||
return
|
||||
}
|
||||
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
|
||||
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
|
||||
} else {
|
||||
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
|
||||
}
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
// 200, not 201: a fill can legitimately create nothing (the region is already
|
||||
// planted), and there is no single resource to point a Location at.
|
||||
c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)})
|
||||
}
|
||||
|
||||
// clearObject soft-removes every active plop in an object.
|
||||
//
|
||||
// Distinct from deleting the object, and — unlike the client-side loop this
|
||||
// replaces — it lands as ONE change set, so undoing a cleared bed is one click
|
||||
// rather than one per plop.
|
||||
func (h *handlers) clearObject(c *gin.Context) {
|
||||
id, ok := parseIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"cleared": n})
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func fillPath(id int64) string { return objectPath(id) + "/fill" }
|
||||
func clearPath(id int64) string { return objectPath(id) + "/clear" }
|
||||
|
||||
// makeFillPlant creates a custom plant and returns its id. (A near-identical
|
||||
// createPlantAPI landed alongside the seed-lot tests; consolidating the two into
|
||||
// one shared helper is a fine follow-up, kept separate here only to avoid a
|
||||
// merge collision on the shared symbol.)
|
||||
func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
||||
t.Helper()
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
||||
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
||||
}
|
||||
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
}
|
||||
|
||||
// seedFillableBed makes a garden with one plantable bed and a custom plant,
|
||||
// returning (gardenID, objectID, plantID).
|
||||
func seedFillableBed(t *testing.T, r *gin.Engine, cookie *http.Cookie, w, h, spacing float64) (int64, int64, int64) {
|
||||
t.Helper()
|
||||
gid := createGardenAPI(t, r, cookie, "G")
|
||||
rec := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||
"kind": "bed", "widthCm": w, "heightCm": h, "plantable": true,
|
||||
}, cookie)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create bed: status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
objID := int64(decodeMap(t, rec.Body.Bytes())["id"].(float64))
|
||||
plantID := makeFillPlant(t, r, cookie, "Fillable", spacing)
|
||||
return gid, objID, plantID
|
||||
}
|
||||
|
||||
// countChangeSets reads the history page and reports how many change sets exist.
|
||||
func countChangeSets(t *testing.T, r *gin.Engine, cookie *http.Cookie, gardenID int64) int {
|
||||
t.Helper()
|
||||
w := doJSON(t, r, http.MethodGet, historyPath(gardenID), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
sets, _ := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
|
||||
return len(sets)
|
||||
}
|
||||
|
||||
// TestFillAndClearAPI covers the two routes end to end through the router.
|
||||
//
|
||||
// These exist because both operations were previously reachable ONLY through the
|
||||
// agent toolbox, so on an instance with no model configured the most valuable
|
||||
// bulk operation in the app did not exist at all.
|
||||
func TestFillAndClearAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
||||
|
||||
// Fill by compass name.
|
||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := decodeMap(t, w.Body.Bytes())
|
||||
created := int(body["created"].(float64))
|
||||
if created == 0 {
|
||||
t.Fatalf("fill created nothing: %s", w.Body.String())
|
||||
}
|
||||
if plops, _ := body["plantings"].([]any); len(plops) != created {
|
||||
t.Errorf("created=%d but returned %d plantings", created, len(plops))
|
||||
}
|
||||
|
||||
// Clear it: one call, and it reports what it removed.
|
||||
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("clear: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != created {
|
||||
t.Errorf("cleared %d, want %d (everything the fill made)", n, created)
|
||||
}
|
||||
|
||||
// Clearing an already-empty bed is a no-op, not an error.
|
||||
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("second clear: status %d", w.Code)
|
||||
}
|
||||
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
|
||||
t.Errorf("second clear removed %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillLayoutAPI covers the layout selector (#77): grid packs denser than the
|
||||
// clump default, and an unknown layout is a 400 rather than a silent clump fill.
|
||||
func TestFillLayoutAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
||||
|
||||
// Clump (default) for the baseline count.
|
||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("clump fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
clumpN := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
||||
t.Fatalf("clear between fills: %d", w.Code)
|
||||
}
|
||||
|
||||
// Grid packs individual plants at true spacing — many more, small plops.
|
||||
w = doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all", "layout": "grid",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("grid fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if gridN := int(decodeMap(t, w.Body.Bytes())["created"].(float64)); gridN <= clumpN {
|
||||
t.Errorf("grid fill created %d, want more than the clump fill's %d", gridN, clumpN)
|
||||
}
|
||||
|
||||
// An unknown layout is a 400, not a silent clump fill.
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all", "layout": "spiral",
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("unknown layout = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
|
||||
// its own corner of the bed.
|
||||
func TestFillRegionSelectionAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
_, objID, plantID := seedFillableBed(t, r, cookie, 400, 400, 20)
|
||||
|
||||
// Neither → 400. Both → 400. Accepting both and silently preferring one
|
||||
// would make a client bug look like a geometry bug.
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{"plantId": plantID}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("no region = %d, want 400", w.Code)
|
||||
}
|
||||
both := map[string]any{
|
||||
"plantId": plantID, "region": "all",
|
||||
"rect": map[string]any{"minXCm": -50, "minYCm": -50, "maxXCm": 50, "maxYCm": 50},
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), both, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("both region and rect = %d, want 400", w.Code)
|
||||
}
|
||||
// An unknown compass name is rejected rather than silently filling nothing.
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "middle-ish",
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("bad region name = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// A zero-area rect is malformed input, not "plant one at the centre". An empty
|
||||
// `"rect": {}` decodes to all-zeros and must be caught the same way.
|
||||
for _, rect := range []map[string]any{
|
||||
{}, // {} → 0,0,0,0
|
||||
{"minXCm": 10, "minYCm": 10, "maxXCm": 10, "maxYCm": 50}, // zero width
|
||||
{"minXCm": 10, "minYCm": 50, "maxXCm": 50, "maxYCm": 50}, // zero height
|
||||
{"minXCm": 50, "minYCm": 50, "maxXCm": 10, "maxYCm": 10}, // inverted
|
||||
} {
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID),
|
||||
map[string]any{"plantId": plantID, "rect": rect}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("degenerate rect %v = %d, want 400", rect, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A rect confined to the NE corner produces plops only there. Local frame:
|
||||
// +x east, -y north.
|
||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID,
|
||||
"rect": map[string]any{"minXCm": 0, "minYCm": -200, "maxXCm": 200, "maxYCm": 0},
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("rect fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
plops, _ := decodeMap(t, w.Body.Bytes())["plantings"].([]any)
|
||||
if len(plops) == 0 {
|
||||
t.Fatal("rect fill created nothing")
|
||||
}
|
||||
for _, raw := range plops {
|
||||
p := raw.(map[string]any)
|
||||
if x, y := p["xCm"].(float64), p["yCm"].(float64); x < 0 || y > 0 {
|
||||
t.Errorf("plop at (%v,%v) outside the NE rect", x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearObjectIsOneChangeSetAPI is the regression test for the behaviour this
|
||||
// endpoint exists to restore.
|
||||
//
|
||||
// The UI used to clear a bed with a loop of PATCHes, and since every service
|
||||
// mutation auto-scopes its own change set, clearing a 40-plop bed wrote 40 of
|
||||
// them — 40 presses of Undo to put the bed back. CLAUDE.md states the rule
|
||||
// directly: multi-row operations record together so they undo as one unit.
|
||||
func TestClearObjectIsOneChangeSetAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
gid, objID, plantID := seedFillableBed(t, r, cookie, 300, 300, 20)
|
||||
|
||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
created := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
||||
if created < 4 {
|
||||
t.Fatalf("need several plops to make this meaningful, got %d", created)
|
||||
}
|
||||
|
||||
before := countChangeSets(t, r, cookie, gid)
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
||||
t.Fatalf("clear: status %d", w.Code)
|
||||
}
|
||||
if after := countChangeSets(t, r, cookie, gid); after != before+1 {
|
||||
t.Errorf("clearing %d plops added %d change sets, want exactly 1", created, after-before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillClearPermissionsAPI: a viewer may look but not fill or clear, and a
|
||||
// stranger gets 404 because existence is masked.
|
||||
func TestFillClearPermissionsAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
owner := registerAndCookie(t, r, "[email protected]")
|
||||
viewer := registerAndCookie(t, r, "[email protected]")
|
||||
stranger := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
gid, objID, plantID := seedFillableBed(t, r, owner, 200, 200, 20)
|
||||
if w := doJSON(t, r, http.MethodPost, sharesPath(gid),
|
||||
map[string]any{"email": "[email protected]", "role": "viewer"}, owner); w.Code != http.StatusCreated {
|
||||
t.Fatalf("share as viewer: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
fillBody := map[string]any{"plantId": plantID, "region": "all"}
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, viewer); w.Code != http.StatusForbidden {
|
||||
t.Errorf("viewer fill = %d, want 403 (they can see it but may not do that)", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, viewer); w.Code != http.StatusForbidden {
|
||||
t.Errorf("viewer clear = %d, want 403", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, stranger); w.Code != http.StatusNotFound {
|
||||
t.Errorf("stranger fill = %d, want 404 (existence masked)", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, stranger); w.Code != http.StatusNotFound {
|
||||
t.Errorf("stranger clear = %d, want 404", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous fill = %d, want 401", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous clear = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,9 @@ import (
|
||||
// the buyer — a lot is never shared along with a garden — so every handler here
|
||||
// scopes to the session actor with no garden in the picture.
|
||||
|
||||
// seedLotFields is the lot half of a create body — every field EXCEPT which plant
|
||||
// it attaches to. seedLotCreateRequest adds a required plantId; the seed-packet
|
||||
// confirm supplies none (the plant comes from its plantId/newPlant choice), so it
|
||||
// embeds these fields directly. Sharing one struct keeps the two request shapes —
|
||||
// and their validation — from drifting apart.
|
||||
type seedLotFields struct {
|
||||
// seedLotCreateRequest is the body for POST /seed-lots.
|
||||
type seedLotCreateRequest struct {
|
||||
PlantID int64 `json:"plantId" binding:"required"`
|
||||
Vendor string `json:"vendor"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
SKU string `json:"sku"`
|
||||
@@ -35,26 +32,13 @@ type seedLotFields struct {
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// toInput builds the service input with no plant attribution; callers that know
|
||||
// the plant (the create handler; the packet confirm) set PlantID afterwards.
|
||||
func (f seedLotFields) toInput() service.SeedLotInput {
|
||||
return service.SeedLotInput{
|
||||
Vendor: f.Vendor, SourceURL: f.SourceURL, SKU: f.SKU, LotCode: f.LotCode,
|
||||
PurchasedAt: f.PurchasedAt, PackedForYear: f.PackedForYear, Quantity: f.Quantity,
|
||||
Unit: f.Unit, CostCents: f.CostCents, GerminationPct: f.GerminationPct, Notes: f.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// seedLotCreateRequest is the body for POST /seed-lots.
|
||||
type seedLotCreateRequest struct {
|
||||
PlantID int64 `json:"plantId" binding:"required"`
|
||||
seedLotFields
|
||||
}
|
||||
|
||||
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
|
||||
in := r.seedLotFields.toInput()
|
||||
in.PlantID = r.PlantID
|
||||
return in
|
||||
return service.SeedLotInput{
|
||||
PlantID: r.PlantID, Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU,
|
||||
LotCode: r.LotCode, PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear,
|
||||
Quantity: r.Quantity, Unit: r.Unit, CostCents: r.CostCents,
|
||||
GerminationPct: r.GerminationPct, Notes: r.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// seedLotUpdateRequest is the body for PATCH /seed-lots/:id: every field
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func seedLotPath(id int64) string {
|
||||
return "/api/v1/seed-lots/" + strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
// decodeList decodes a bare JSON array body. Seed lot listing returns the array
|
||||
// directly rather than wrapping it (unlike /journal's {"entries": …}), so a
|
||||
// helper that assumed an object would quietly read nothing.
|
||||
func decodeList(t *testing.T, body []byte) []any {
|
||||
t.Helper()
|
||||
var out []any
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
t.Fatalf("decode list: %v (%s)", err, body)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// createPlantAPI makes a custom plant and returns its id.
|
||||
func createPlantAPI(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
||||
t.Helper()
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
||||
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
||||
}
|
||||
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
}
|
||||
|
||||
// TestSeedLotCrudAPI walks the whole seed lot lifecycle over HTTP.
|
||||
//
|
||||
// It exists for the reason CLAUDE.md gives: service tests cannot see a route
|
||||
// that was never registered, or one registered with the wrong :param name. Every
|
||||
// other handler file had a sibling API test; this group did not, which is the
|
||||
// state PATCH/DELETE /journal/:id shipped in — implemented, unit-tested, and
|
||||
// completely unreachable.
|
||||
func TestSeedLotCrudAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
plantID := createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
||||
|
||||
// Create.
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||
"plantId": plantID, "vendor": "Johnny's", "sku": "2761",
|
||||
"quantity": 100, "unit": "seeds", "packedForYear": 2026, "costCents": 495,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
lot := decodeMap(t, w.Body.Bytes())
|
||||
id := int64(lot["id"].(float64))
|
||||
if lot["vendor"] != "Johnny's" || lot["unit"] != "seeds" {
|
||||
t.Errorf("unexpected lot: %+v", lot)
|
||||
}
|
||||
|
||||
// GET by id — the route most likely to be missing or mis-registered.
|
||||
w = doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := decodeMap(t, w.Body.Bytes()); int64(got["id"].(float64)) != id {
|
||||
t.Errorf("get returned id %v, want %d", got["id"], id)
|
||||
}
|
||||
|
||||
// List, and the ?plantId= filter.
|
||||
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if n := len(decodeList(t, w.Body.Bytes())); n != 1 {
|
||||
t.Fatalf("list returned %d lots, want 1: %s", n, w.Body.String())
|
||||
}
|
||||
|
||||
other := createPlantAPI(t, r, cookie, "Cherokee Purple", 45)
|
||||
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+strconv.FormatInt(other, 10), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("filtered list: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||
t.Errorf("filter by a plant with no lots returned %d, want 0", n)
|
||||
}
|
||||
// A bad plantId filter is 400, whether non-numeric or out of range — the
|
||||
// handler rejects id < 1, not just unparseable strings.
|
||||
for _, bad := range []string{"nope", "0", "-1"} {
|
||||
if w := doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+bad, nil, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("plantId=%q filter = %d, want 400", bad, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH with the current version.
|
||||
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||
"vendor": "Fedco", "version": lot["version"],
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
updated := decodeMap(t, w.Body.Bytes())
|
||||
if updated["vendor"] != "Fedco" {
|
||||
t.Errorf("vendor = %v, want Fedco", updated["vendor"])
|
||||
}
|
||||
if updated["version"].(float64) != lot["version"].(float64)+1 {
|
||||
t.Errorf("patch didn't bump version: %v -> %v", lot["version"], updated["version"])
|
||||
}
|
||||
|
||||
// A stale version conflicts and carries the current row back, so the client
|
||||
// can rebase without a second request.
|
||||
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||
"vendor": "stale", "version": lot["version"],
|
||||
}, cookie)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("stale patch: status %d, want 409", w.Code)
|
||||
}
|
||||
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["vendor"] != "Fedco" {
|
||||
t.Errorf("409 body missing the current row: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// DELETE.
|
||||
if w := doJSON(t, r, http.MethodDelete, seedLotPath(id), nil, cookie); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie); w.Code != http.StatusNotFound {
|
||||
t.Errorf("get after delete = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedLotRemainingIsDerivedAPI checks that `remaining` reflects plantings
|
||||
// through the HTTP surface, not just in the service.
|
||||
//
|
||||
// DESIGN.md makes derivation load-bearing — "a decremented column drifts the
|
||||
// moment a planting is edited behind its back" — so a route that returned a
|
||||
// stored or stale figure would break the invariant silently, and the number is
|
||||
// the whole reason anyone opens the seed shelf.
|
||||
func TestSeedLotRemainingIsDerivedAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
plantID := createPlantAPI(t, r, cookie, "Beans", 10)
|
||||
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||
"plantId": plantID, "quantity": 50, "unit": "seeds",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create lot: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
|
||||
gid := createGardenAPI(t, r, cookie, "G")
|
||||
w = doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||
"kind": "bed", "widthCm": 100, "heightCm": 100, "plantable": true,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
|
||||
// Plant 12 of them against the lot.
|
||||
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||
"plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 20, "count": 12, "seedLotId": lotID,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create planting: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get lot: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
got := decodeMap(t, w.Body.Bytes())
|
||||
if rem, ok := got["remaining"].(float64); !ok || rem != 38 {
|
||||
t.Errorf("remaining = %v, want 38 (50 bought - 12 planted): %s", got["remaining"], w.Body.String())
|
||||
}
|
||||
|
||||
// Over-plant it: 45 more (57 total against 50 bought) drives remaining
|
||||
// NEGATIVE. That's deliberate — the number is a derived truth about what
|
||||
// you've committed, not a floor clamped at zero, and "you've planted more than
|
||||
// you bought" is exactly the signal a gardener wants rather than a hidden -7.
|
||||
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||
"plantId": plantID, "xCm": 40, "yCm": 40, "radiusCm": 20, "count": 45, "seedLotId": lotID,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("over-plant: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||
if rem, ok := decodeMap(t, w.Body.Bytes())["remaining"].(float64); !ok || rem != -7 {
|
||||
t.Errorf("remaining after over-planting = %v, want -7 (50 - 57)", rem)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedLotsArePrivateAPI checks the ACL through the router.
|
||||
//
|
||||
// Lots are private to the buyer and deliberately never travel with a shared
|
||||
// garden, so another user must not be able to read or edit one. Per the
|
||||
// project's convention, no-access is ErrNotFound rather than ErrForbidden —
|
||||
// existence is masked — so every one of these is a 404, not a 403.
|
||||
func TestSeedLotsArePrivateAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
alice := registerAndCookie(t, r, "[email protected]")
|
||||
bob := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
plantID := createPlantAPI(t, r, alice, "Alice's garlic", 15)
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||
"plantId": plantID, "vendor": "Secret Vendor", "quantity": 10, "unit": "bulbs",
|
||||
}, alice)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("alice create: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
body any
|
||||
}{
|
||||
{"get", http.MethodGet, nil},
|
||||
{"patch", http.MethodPatch, map[string]any{"vendor": "hijacked", "version": 1}},
|
||||
{"delete", http.MethodDelete, nil},
|
||||
} {
|
||||
if w := doJSON(t, r, tc.method, seedLotPath(lotID), tc.body, bob); w.Code != http.StatusNotFound {
|
||||
t.Errorf("bob %s = %d, want 404 (existence is masked)", tc.name, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Bob's own listing must not include it either.
|
||||
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, bob)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("bob list: status %d", w.Code)
|
||||
}
|
||||
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||
t.Errorf("bob sees %d of alice's lots, want 0: %s", n, w.Body.String())
|
||||
}
|
||||
|
||||
// And it's still intact for alice.
|
||||
if w := doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, alice); w.Code != http.StatusOK {
|
||||
t.Errorf("alice lost access to her own lot: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedLotsRequireAuthAPI: the group is behind requireAuth, and an
|
||||
// unauthenticated caller gets 401 rather than an empty list.
|
||||
func TestSeedLotsRequireAuthAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/seed-lots"},
|
||||
{http.MethodPost, "/api/v1/seed-lots"},
|
||||
{http.MethodGet, seedLotPath(1)},
|
||||
{http.MethodPatch, seedLotPath(1)},
|
||||
{http.MethodDelete, seedLotPath(1)},
|
||||
} {
|
||||
if w := doJSON(t, r, tc.method, tc.path, nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s = %d, want 401", tc.method, tc.path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// Seed-packet capture (#81). Two steps, deliberately separate:
|
||||
// POST /seed-lots/scan multipart image → a proposal (reads only)
|
||||
// POST /seed-lots/from-packet confirmed proposal → a plant + lot
|
||||
// The scan never writes; creation happens only from an explicit confirm, so a
|
||||
// misread can't add anything to the catalog on its own.
|
||||
|
||||
// scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at
|
||||
// 25 MiB; this is a little over that for the multipart envelope. A phone photo is
|
||||
// a few MB, so this is generous.
|
||||
const scanUploadLimit = 30 << 20
|
||||
|
||||
// scanReadTimeout is how long we allow the image upload to take. The server's
|
||||
// default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo
|
||||
// on a slow phone connection, so this endpoint extends it — the same
|
||||
// ResponseController mechanism the SSE path uses for writes (#78).
|
||||
const scanReadTimeout = 60 * time.Second
|
||||
|
||||
// scanWriteTimeout extends the write deadline for the same reason. The server's
|
||||
// absolute WriteTimeout (30s) is measured from the start of the request, but this
|
||||
// handler's response can't be written until AFTER a slow upload AND a live vision
|
||||
// call — together easily past 30s. Without this, a successful extraction's
|
||||
// response is silently dropped: the exact failure mode #78 fixed for SSE.
|
||||
const scanWriteTimeout = 120 * time.Second
|
||||
|
||||
// scanSeedPacket reads an uploaded packet photo and returns a proposal.
|
||||
func (h *handlers) scanSeedPacket(c *gin.Context) {
|
||||
// Extend both deadlines for the (potentially large, potentially slow) upload
|
||||
// and the live vision call that follows. Best-effort: if the writer doesn't
|
||||
// support it, the server defaults apply.
|
||||
rc := http.NewResponseController(c.Writer)
|
||||
_ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout))
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout))
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
// A body over scanUploadLimit trips MaxBytesReader — that's 413, not a
|
||||
// malformed request. Everything else here is a genuinely missing/garbled
|
||||
// multipart field.
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||
return
|
||||
}
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field")
|
||||
return
|
||||
}
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
// Opening the parsed upload failed on our side, not the client's.
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not read the uploaded image")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so
|
||||
// everything downstream — including the vision model — only sees a format it
|
||||
// can read. This is where an iPhone HEIC becomes usable.
|
||||
jpeg, _, err := imagenorm.Normalize(f, imagenorm.Options{})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, imagenorm.ErrTooLarge):
|
||||
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||
case errors.Is(err, imagenorm.ErrUnsupported):
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)")
|
||||
default:
|
||||
// A read or re-encode fault is ours, not bad input.
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not process the uploaded image")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg)
|
||||
if err != nil {
|
||||
// A missing vision model surfaces as ErrInvalidInput from the service; give
|
||||
// it a clearer message than the generic 400, since the UI shouldn't have
|
||||
// offered the button at all in that case.
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance")
|
||||
return
|
||||
}
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, prop)
|
||||
}
|
||||
|
||||
// fromPacketRequest confirms a proposal: exactly one of plantId (attach to an
|
||||
// existing plant) or newPlant (create a variety), plus the lot to record. The lot
|
||||
// is seedLotFields — the create body's lot half WITHOUT plantId, since the plant
|
||||
// comes from the plantId/newPlant choice, not the lot body.
|
||||
type fromPacketRequest struct {
|
||||
PlantID *int64 `json:"plantId"`
|
||||
NewPlant *plantCreateRequest `json:"newPlant"`
|
||||
Lot seedLotFields `json:"lot"`
|
||||
}
|
||||
|
||||
// createFromPacket turns a confirmed proposal into a plant + lot.
|
||||
func (h *handlers) createFromPacket(c *gin.Context) {
|
||||
var req fromPacketRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required")
|
||||
return
|
||||
}
|
||||
|
||||
confirm := service.PacketConfirm{
|
||||
PlantID: req.PlantID,
|
||||
Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it
|
||||
}
|
||||
if req.NewPlant != nil {
|
||||
in := req.NewPlant.toInput()
|
||||
confirm.NewPlant = &in
|
||||
}
|
||||
|
||||
res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, res)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
// packetEngine builds an engine whose service reads seed packets via the given
|
||||
// canned extractor, so the scan endpoint can be tested without a live model.
|
||||
func packetEngine(t *testing.T, cfg *config.Config, extract func() (vision.SeedPacket, error)) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
svc := service.New(db, cfg, service.WithPacketExtractor(
|
||||
func(context.Context, string, string, []byte) (vision.SeedPacket, error) { return extract() },
|
||||
))
|
||||
return New(cfg, svc)
|
||||
}
|
||||
|
||||
// visionCfg is a config with a vision model + key configured, so packet scanning
|
||||
// is available.
|
||||
func visionCfg() *config.Config {
|
||||
c := localCfg()
|
||||
c.Agent = config.AgentConfig{OllamaCloudAPIKey: "k", VisionModel: "ollama-cloud/vision:cloud"}
|
||||
return c
|
||||
}
|
||||
|
||||
// pngUpload builds a multipart body with a real PNG under the "image" field.
|
||||
func pngUpload(t *testing.T) (body *bytes.Buffer, contentType string) {
|
||||
t.Helper()
|
||||
var img bytes.Buffer
|
||||
if err := png.Encode(&img, image.NewRGBA(image.Rect(0, 0, 32, 24))); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
part, err := w.CreateFormFile("image", "packet.png")
|
||||
if err != nil {
|
||||
t.Fatalf("form file: %v", err)
|
||||
}
|
||||
part.Write(img.Bytes())
|
||||
w.Close()
|
||||
return &buf, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func doMultipart(t *testing.T, r *gin.Engine, path, contentType string, body *bytes.Buffer, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestScanSeedPacketAPI: a multipart image → a proposal, end to end through the
|
||||
// router. The extractor is canned; imagenorm runs for real on the uploaded PNG.
|
||||
func TestScanSeedPacketAPI(t *testing.T) {
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
||||
return vision.SeedPacket{Species: "garlic", Variety: "Music", Category: "vegetable"}, nil
|
||||
})
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
// A matching plant so the proposal has a candidate.
|
||||
createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
||||
|
||||
body, ct := pngUpload(t)
|
||||
w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct, body, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("scan: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
res := decodeMap(t, w.Body.Bytes())
|
||||
pkt, _ := res["packet"].(map[string]any)
|
||||
if pkt["variety"] != "Music" {
|
||||
t.Errorf("packet variety = %v", pkt["variety"])
|
||||
}
|
||||
if cands, _ := res["candidates"].([]any); len(cands) == 0 {
|
||||
t.Error("expected a candidate match for Music Garlic")
|
||||
}
|
||||
if res["suggestedName"] != "Music" {
|
||||
t.Errorf("suggestedName = %v", res["suggestedName"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanSeedPacketErrorsAPI: no image, unreadable bytes, and vision-not-
|
||||
// configured each get their own clear status.
|
||||
func TestScanSeedPacketErrorsAPI(t *testing.T) {
|
||||
// Vision configured, so we reach imagenorm / the extractor.
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
||||
return vision.SeedPacket{Variety: "X"}, nil
|
||||
})
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
// No file field → 400.
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", "multipart/form-data; boundary=x", bytes.NewBufferString(""), cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("no image = %d, want 400", w.Code)
|
||||
}
|
||||
// A file that isn't an image → 400 (imagenorm rejects it).
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, _ := mw.CreateFormFile("image", "notes.txt")
|
||||
part.Write([]byte("this is not an image"))
|
||||
mw.Close()
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("non-image = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// Vision NOT configured → 503, even with a valid image.
|
||||
r2 := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie2 := registerAndCookie(t, r2, "[email protected]")
|
||||
body, ct := pngUpload(t)
|
||||
if w := doMultipart(t, r2, "/api/v1/seed-lots/scan", ct, body, cookie2); w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("no vision model = %d, want 503", w.Code)
|
||||
}
|
||||
|
||||
// Unauthenticated → 401.
|
||||
b3, ct3 := pngUpload(t)
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct3, b3, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous scan = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanSeedPacketTooLargeAPI: a body over the multipart cap trips
|
||||
// MaxBytesReader, which must surface as 413 (too large), not 400 (malformed).
|
||||
func TestScanSeedPacketTooLargeAPI(t *testing.T) {
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, _ := mw.CreateFormFile("image", "big.png")
|
||||
// A hair over scanUploadLimit (30 MiB) so MaxBytesReader trips during parsing.
|
||||
part.Write(bytes.Repeat([]byte{0}, (30<<20)+1024))
|
||||
mw.Close()
|
||||
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized upload = %d, want 413", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketAPI: confirm → plant + lot. No model involved, so the full
|
||||
// path runs through the router.
|
||||
func TestCreateFromPacketAPI(t *testing.T) {
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
// New plant + lot.
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"newPlant": map[string]any{"name": "Music Garlic", "category": "vegetable", "color": "#4a7c3f", "icon": "🧄", "spacingCm": 15},
|
||||
"lot": map[string]any{"vendor": "Johnny's", "quantity": 8, "unit": "bulbs"},
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("new plant confirm: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
res := decodeMap(t, w.Body.Bytes())
|
||||
if res["plantIsNew"] != true {
|
||||
t.Errorf("plantIsNew = %v, want true", res["plantIsNew"])
|
||||
}
|
||||
plantObj, _ := res["plant"].(map[string]any)
|
||||
plantID := int64(plantObj["id"].(float64))
|
||||
lotObj, _ := res["lot"].(map[string]any)
|
||||
if int64(lotObj["plantId"].(float64)) != plantID {
|
||||
t.Errorf("lot not attributed to the new plant")
|
||||
}
|
||||
|
||||
// Existing plant + lot.
|
||||
w = doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"plantId": plantID,
|
||||
"lot": map[string]any{"vendor": "Fedco", "quantity": 10, "unit": "bulbs"},
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("existing plant confirm: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if decodeMap(t, w.Body.Bytes())["plantIsNew"] != false {
|
||||
t.Error("plantIsNew should be false for an existing plant")
|
||||
}
|
||||
|
||||
// Both plantId and newPlant → 400.
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"plantId": plantID,
|
||||
"newPlant": map[string]any{"name": "X", "category": "vegetable", "color": "#4a7c3f", "icon": "🌱"},
|
||||
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("both plant choices = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// Neither → 400.
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("neither plant choice = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// Unauthenticated → 401.
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous confirm = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapabilitiesReportsVision: /capabilities advertises vision only when a
|
||||
// vision model is configured.
|
||||
func TestCapabilitiesReportsVision(t *testing.T) {
|
||||
on := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie := registerAndCookie(t, on, "[email protected]")
|
||||
w := doJSON(t, on, http.MethodGet, "/api/v1/capabilities", nil, cookie)
|
||||
if decodeMap(t, w.Body.Bytes())["vision"] != true {
|
||||
t.Errorf("vision should be true with a model configured: %s", w.Body.String())
|
||||
}
|
||||
|
||||
off := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie2 := registerAndCookie(t, off, "[email protected]")
|
||||
w = doJSON(t, off, http.MethodGet, "/api/v1/capabilities", nil, cookie2)
|
||||
if decodeMap(t, w.Body.Bytes())["vision"] != false {
|
||||
t.Errorf("vision should be false with no model: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// Instance settings (#79): admin-only, instance-wide. The authoritative admin
|
||||
// check is in the service; requireAdmin here is a cheap early 403 that also
|
||||
// keeps the route group readable.
|
||||
|
||||
// requireAdmin rejects a non-admin actor. It runs after requireAuth, so the
|
||||
// actor is already resolved and carries IsAdmin — no extra query. Returns 403
|
||||
// (not 404): a logged-in user knows settings exist, they just may not touch them.
|
||||
func (h *handlers) requireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !mustActor(c).IsAdmin {
|
||||
writeAPIError(c, http.StatusForbidden, "FORBIDDEN", "admin access required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// settingsResponse is what GET/PATCH /settings return. It carries the stored
|
||||
// settings plus a read-only view of what's resolved and live, so the UI can show
|
||||
// "inheriting ollama-cloud/glm-5.2:cloud from the environment" and whether a key
|
||||
// is present — without ever exposing the key itself.
|
||||
type settingsResponse struct {
|
||||
Settings *domain.InstanceSettings `json:"settings"`
|
||||
// Effective is the configuration actually in force after layering settings
|
||||
// over the environment.
|
||||
Effective effectiveView `json:"effective"`
|
||||
// Auth is the sign-in configuration, read-only (see authView).
|
||||
Auth authView `json:"auth"`
|
||||
}
|
||||
|
||||
// authView is the environment-driven sign-in configuration the Settings page
|
||||
// shows under "Who gets in": PANSY_REGISTRATION, PANSY_LOCAL_AUTH and the OIDC
|
||||
// issuer. It is reported so an admin can see what is in force without shell
|
||||
// access; none of it is editable at runtime (auth policy deploys with the
|
||||
// environment on purpose — see README). Only the issuer URL is exposed, never
|
||||
// the client id or secret.
|
||||
type authView struct {
|
||||
// Registration is "open" or "closed" — whether local self-service signup is
|
||||
// allowed. OIDC provisioning ignores it (the IdP gates access).
|
||||
Registration string `json:"registration"`
|
||||
// LocalAuth is whether email/password sign-in is offered at all.
|
||||
LocalAuth bool `json:"localAuth"`
|
||||
// OIDC is whether single sign-on is fully configured; OIDCIssuer is the
|
||||
// discovery URL as configured (may be set while OIDC is still incomplete).
|
||||
OIDC bool `json:"oidc"`
|
||||
OIDCIssuer string `json:"oidcIssuer"`
|
||||
OIDCLabel string `json:"oidcLabel"`
|
||||
}
|
||||
|
||||
type effectiveView struct {
|
||||
Model string `json:"model"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// HasApiKey reports whether OLLAMA_CLOUD_API_KEY is set. The key itself is
|
||||
// never serialized — an admin may know one exists, not what it is.
|
||||
HasApiKey bool `json:"hasApiKey"`
|
||||
// AgentLive is whether the assistant Runner is actually built right now. It
|
||||
// can be false even when Enabled+HasApiKey are true (an unresolvable model),
|
||||
// which is exactly the case the UI needs to surface.
|
||||
AgentLive bool `json:"agentLive"`
|
||||
// VisionModel is the resolved seed-packet model (DB-over-env). VisionReady is
|
||||
// whether capture can actually be offered (a key and a model).
|
||||
VisionModel string `json:"visionModel"`
|
||||
VisionReady bool `json:"visionReady"`
|
||||
}
|
||||
|
||||
// settingsPayload builds the response, or an error. It does NOT swallow an
|
||||
// EffectiveConfig failure into a misleading empty "effective" view — an empty
|
||||
// view would report no model and no key, which reads as "nothing configured"
|
||||
// rather than "we couldn't read it". Since EffectiveConfig re-reads the same row
|
||||
// GetInstanceSettings just returned, a failure here is a genuine DB fault worth
|
||||
// surfacing as a 500, not papering over. It also resolves the agent and vision
|
||||
// views from ONE row read rather than fetching the single-row table twice.
|
||||
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
|
||||
eff, vis, err := h.svc.EffectiveConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
return settingsResponse{}, err
|
||||
}
|
||||
return settingsResponse{
|
||||
Settings: st,
|
||||
Effective: effectiveView{
|
||||
Model: eff.Model,
|
||||
Enabled: eff.Enabled,
|
||||
HasApiKey: eff.APIKey != "",
|
||||
AgentLive: h.agent.get() != nil,
|
||||
VisionModel: vis.Model,
|
||||
VisionReady: vis.Ready(),
|
||||
},
|
||||
Auth: authView{
|
||||
Registration: h.cfg.Registration,
|
||||
LocalAuth: h.cfg.LocalAuth,
|
||||
OIDC: h.cfg.OIDCReady(),
|
||||
OIDCIssuer: h.cfg.OIDC.Issuer,
|
||||
OIDCLabel: h.cfg.OIDC.ButtonLabel,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *handlers) getSettings(c *gin.Context) {
|
||||
st, err := h.svc.GetInstanceSettings(c.Request.Context(), mustActor(c).ID)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
payload, err := h.settingsPayload(c, st)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// settingsUpdateRequest is the PATCH body. agentModel "" means inherit the env
|
||||
// var. agentEnabled is json.RawMessage so an explicit null (inherit) is
|
||||
// distinguishable from an absent field and from true/false.
|
||||
type settingsUpdateRequest struct {
|
||||
AgentModel string `json:"agentModel"`
|
||||
AgentEnabled json.RawMessage `json:"agentEnabled"`
|
||||
VisionModel string `json:"visionModel"`
|
||||
Version int64 `json:"version" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *handlers) updateSettings(c *gin.Context) {
|
||||
var req settingsUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a current version is required")
|
||||
return
|
||||
}
|
||||
|
||||
// agentEnabled: absent or null → inherit (nil); true/false → explicit override.
|
||||
// The shared parseNullable does exactly this three-way decode; present is
|
||||
// irrelevant here because absent and null both mean "inherit".
|
||||
enabled, _, err := parseNullable[bool](req.AgentEnabled)
|
||||
if err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "agentEnabled must be true, false, or null")
|
||||
return
|
||||
}
|
||||
|
||||
st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{
|
||||
AgentModel: req.AgentModel,
|
||||
AgentEnabled: enabled,
|
||||
VisionModel: req.VisionModel,
|
||||
Version: req.Version,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrVersionConflict) {
|
||||
writeVersionConflict(c, st)
|
||||
return
|
||||
}
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply the change to the LIVE assistant. Detached from the request context:
|
||||
// the write is committed and the rebuild describes it, so a client that hangs
|
||||
// up now must not leave the running Runner out of step with the stored
|
||||
// settings. Mirrors the same reasoning as the history-write detachment.
|
||||
h.agent.rebuild(context.WithoutCancel(c.Request.Context()))
|
||||
|
||||
payload, err := h.settingsPayload(c, st)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
)
|
||||
|
||||
// agentCfg is a config with the assistant configured — a (fake) key, on, and a
|
||||
// resolvable model. The key isn't real, but ValidateAgentModel/NewRunner only
|
||||
// PARSE the spec (no live call), so a Runner still builds and capabilities
|
||||
// reports it live. That's enough to exercise the runtime on/off swap.
|
||||
func agentCfg() *config.Config {
|
||||
c := localCfg()
|
||||
c.Agent = config.AgentConfig{
|
||||
Model: "ollama-cloud/glm-5.2:cloud",
|
||||
OllamaCloudAPIKey: "test-key",
|
||||
Enabled: true,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func settingsVersion(t *testing.T, r *gin.Engine, cookie *http.Cookie) int64 {
|
||||
t.Helper()
|
||||
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get settings: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
st, _ := decodeMap(t, w.Body.Bytes())["settings"].(map[string]any)
|
||||
return int64(st["version"].(float64))
|
||||
}
|
||||
|
||||
// TestSettingsAdminOnly: the first registered user is admin and can read/write
|
||||
// settings; a second user is not and gets 403 (not 404 — settings aren't a
|
||||
// masked resource).
|
||||
func TestSettingsAdminOnly(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]") // first user → admin
|
||||
member := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin); w.Code != http.StatusOK {
|
||||
t.Fatalf("admin GET settings: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, member); w.Code != http.StatusForbidden {
|
||||
t.Errorf("member GET settings: status %d, want 403", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "x", "version": 1}, member); w.Code != http.StatusForbidden {
|
||||
t.Errorf("member PATCH settings: status %d, want 403", w.Code)
|
||||
}
|
||||
// Unauthenticated is 401, before the admin check.
|
||||
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous GET settings: status %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsInheritFromEnv: an untouched instance reports the env model as
|
||||
// effective, and an empty stored model keeps inheriting it.
|
||||
func TestSettingsInheritFromEnv(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := decodeMap(t, w.Body.Bytes())
|
||||
st := body["settings"].(map[string]any)
|
||||
eff := body["effective"].(map[string]any)
|
||||
|
||||
if st["agentModel"] != "" {
|
||||
t.Errorf("stored model = %v, want empty (inherit)", st["agentModel"])
|
||||
}
|
||||
if eff["model"] != "ollama-cloud/glm-5.2:cloud" {
|
||||
t.Errorf("effective model = %v, want the env value", eff["model"])
|
||||
}
|
||||
if eff["hasApiKey"] != true || eff["agentLive"] != true {
|
||||
t.Errorf("effective = %+v, want a key present and the agent live", eff)
|
||||
}
|
||||
|
||||
// The read-only sign-in view the Settings page renders under "Who gets in".
|
||||
// These come straight from the environment config, so the shape is what's
|
||||
// asserted: a registration mode, a local-auth flag, and no secret material.
|
||||
auth, ok := body["auth"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("settings response has no auth view: %v", body)
|
||||
}
|
||||
if reg := auth["registration"]; reg != "open" && reg != "closed" {
|
||||
t.Errorf("auth.registration = %v, want open or closed", reg)
|
||||
}
|
||||
if _, isBool := auth["localAuth"].(bool); !isBool {
|
||||
t.Errorf("auth.localAuth = %v, want a bool", auth["localAuth"])
|
||||
}
|
||||
for _, k := range []string{"clientId", "clientSecret", "oidcClientSecret"} {
|
||||
if _, present := auth[k]; present {
|
||||
t.Errorf("auth view exposes %q — secrets must never leave the environment", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
|
||||
// effect on the LIVE assistant, with no restart. Driven entirely through HTTP.
|
||||
func TestSettingsUpdateSwapsTheRunner(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
capsAgent := func() bool {
|
||||
w := doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
||||
return decodeMap(t, w.Body.Bytes())["agent"] == true
|
||||
}
|
||||
|
||||
// Configured and on out of the box.
|
||||
if !capsAgent() {
|
||||
t.Fatal("assistant should be live at boot with a key + enabled")
|
||||
}
|
||||
|
||||
// Turn it OFF via settings → capabilities flips immediately.
|
||||
v := settingsVersion(t, r, admin)
|
||||
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("disable: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if capsAgent() {
|
||||
t.Error("assistant still live after being disabled — the runner wasn't swapped")
|
||||
}
|
||||
// Chat now refuses, at runtime, on a route that still exists.
|
||||
gid := createGardenAPI(t, r, admin, "G")
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
||||
map[string]any{"gardenId": gid, "message": "hi"}, admin); w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("chat while disabled: status %d, want 503", w.Code)
|
||||
}
|
||||
|
||||
// Turn it back ON, with an explicit model, and confirm it's live again and the
|
||||
// effective model reflects the change.
|
||||
v = settingsVersion(t, r, admin)
|
||||
w = doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{
|
||||
"agentModel": "ollama-cloud/kimi-k2.6:cloud", "agentEnabled": true, "version": v,
|
||||
}, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("re-enable: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if eff := decodeMap(t, w.Body.Bytes())["effective"].(map[string]any); eff["model"] != "ollama-cloud/kimi-k2.6:cloud" {
|
||||
t.Errorf("effective model = %v after change, want the new one", eff["model"])
|
||||
}
|
||||
if !capsAgent() {
|
||||
t.Error("assistant not live after being re-enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsRejectsBadModel: a spec that won't resolve is a 400 at save time,
|
||||
// not a broken assistant on the next turn.
|
||||
func TestSettingsRejectsBadModel(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
v := settingsVersion(t, r, admin)
|
||||
|
||||
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("bad model: status %d, want 400", w.Code)
|
||||
}
|
||||
// The message says which field and which spec, so the page can show a reason
|
||||
// rather than a bare "invalid input".
|
||||
var body struct {
|
||||
Error struct{ Code, Message string } `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode bad-model body: %v", err)
|
||||
}
|
||||
if body.Error.Code != "INVALID_INPUT" || !strings.Contains(body.Error.Message, "chat model") || !strings.Contains(body.Error.Message, "nonesuch/model") {
|
||||
t.Errorf("bad model error = %+v, want INVALID_INPUT naming the chat model and spec", body.Error)
|
||||
}
|
||||
// agentEnabled must be a bool or null, not a string.
|
||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("string agentEnabled: status %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsVersionConflict: a stale version 409s and carries the current row.
|
||||
func TestSettingsVersionConflict(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
v := settingsVersion(t, r, admin)
|
||||
|
||||
// First write succeeds and bumps the version.
|
||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin); w.Code != http.StatusOK {
|
||||
t.Fatalf("first update: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Reusing the old version conflicts.
|
||||
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": true, "version": v}, admin)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("stale update: status %d, want 409", w.Code)
|
||||
}
|
||||
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["version"].(float64) != float64(v+1) {
|
||||
t.Errorf("409 body missing the current row at the bumped version: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsSwapUnderRace runs settings saves concurrently with chat requests,
|
||||
// so `go test -race` proves the atomic swap of the live Runner is safe against
|
||||
// in-flight readers. The whole point of the atomic.Pointer is this: without it,
|
||||
// toggling the assistant while a request reads it is a data race.
|
||||
func TestSettingsSwapUnderRace(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
done := make(chan struct{})
|
||||
// Readers hammer capabilities, whose h.agent.get() is the SAME atomic Load the
|
||||
// chat handler does — so this races the pointer read against the writer's swap
|
||||
// without ever invoking the model (a real Run would hit the network on a fake
|
||||
// key). If get() is race-clean here it is race-clean in chat.
|
||||
for i := 0; i < 4; i++ {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
// Writer: flip the assistant on and off, swapping the pointer each time.
|
||||
for i := 0; i < 12; i++ {
|
||||
v := settingsVersion(t, r, admin)
|
||||
doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": i%2 == 0, "version": v}, admin)
|
||||
}
|
||||
close(done)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// streamFrames spins up a real http.Server with the given WriteTimeout and an
|
||||
// SSE handler that emits `frames` data frames, one every `tick`, then returns.
|
||||
// It reports how many frames the client actually received and any read error —
|
||||
// the only vantage point from which the deadline failures in #78/#87 are
|
||||
// visible, since the writes themselves return nil when the bytes are dropped.
|
||||
func streamFrames(t *testing.T, serverWriteTimeout, tick time.Duration, frames int) (int, error) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/stream", func(c *gin.Context) {
|
||||
s := openEventStream(c)
|
||||
for i := 0; i < frames; i++ {
|
||||
time.Sleep(tick)
|
||||
s.send(chatEvent{Error: "frame"})
|
||||
}
|
||||
})
|
||||
|
||||
srv := httptest.NewUnstartedServer(r)
|
||||
srv.Config.WriteTimeout = serverWriteTimeout
|
||||
srv.Start()
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := srv.Client().Get(srv.URL + "/stream")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := 0
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
for sc.Scan() {
|
||||
if strings.HasPrefix(sc.Text(), "data: ") {
|
||||
got++
|
||||
}
|
||||
}
|
||||
return got, sc.Err()
|
||||
}
|
||||
|
||||
// TestEventStreamOutlivesServerWriteTimeout is the regression test for #78.
|
||||
//
|
||||
// http.Server.WriteTimeout is an ABSOLUTE deadline measured from when the
|
||||
// request header was read — not an idle timeout — so a streaming response is cut
|
||||
// once it passes, however recently the handler wrote. pansy sets it to 30s while
|
||||
// an agent turn may run for minutes. openEventStream must override it.
|
||||
//
|
||||
// This has to be asserted from the CLIENT side because the failure cannot be
|
||||
// observed from the handler: writes made after the deadline return err == nil
|
||||
// and their bytes are silently discarded. A test that checked the return of
|
||||
// io.WriteString would pass against the bug.
|
||||
func TestEventStreamOutlivesServerWriteTimeout(t *testing.T) {
|
||||
// sseWriteTimeout stays at its 30s default here, so the per-frame refresh
|
||||
// keeps the stream alive with a huge margin — CI slowness only ever makes
|
||||
// this pass more surely. The server's 300ms WriteTimeout is the thing being
|
||||
// overridden; frames straddle it (300ms/600ms/900ms).
|
||||
got, err := streamFrames(t, 300*time.Millisecond, 300*time.Millisecond, 3)
|
||||
if err != nil {
|
||||
t.Errorf("client read error after %d/3 frames: %v", got, err)
|
||||
}
|
||||
if got != 3 {
|
||||
t.Errorf("client received %d frames, want 3 — the stream was cut at the server WriteTimeout", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventStreamRefreshesDeadlinePerFrame guards the #87 fix specifically: the
|
||||
// write deadline is refreshed on EVERY frame, not set once.
|
||||
//
|
||||
// A set-once deadline is a plausible "simplification" and it reintroduces the
|
||||
// unbounded-block risk the per-frame refresh exists to prevent — yet it would
|
||||
// sail through the test above, whose whole run is far under sseWriteTimeout. So
|
||||
// shrink sseWriteTimeout below the stream's total duration and send frames whose
|
||||
// gap stays comfortably under it: per-frame refresh delivers them all, while a
|
||||
// deadline set once at open would expire mid-stream and cut it short.
|
||||
func TestEventStreamRefreshesDeadlinePerFrame(t *testing.T) {
|
||||
orig := sseWriteTimeout
|
||||
sseWriteTimeout = 400 * time.Millisecond
|
||||
t.Cleanup(func() { sseWriteTimeout = orig })
|
||||
|
||||
// The server WriteTimeout is generous (5s), so it isn't the limiter — the
|
||||
// per-frame sseWriteTimeout is. 8 frames at a 100ms tick span 800ms, well past
|
||||
// the 400ms deadline, but each 100ms gap is a 4× margin under it.
|
||||
got, err := streamFrames(t, 5*time.Second, 100*time.Millisecond, 8)
|
||||
if err != nil {
|
||||
t.Errorf("client read error after %d/8 frames: %v", got, err)
|
||||
}
|
||||
if got != 8 {
|
||||
t.Errorf("client received %d frames, want 8 — a set-once deadline would cut the stream at ~%v; the refresh must be per-frame",
|
||||
got, sseWriteTimeout)
|
||||
}
|
||||
}
|
||||
@@ -74,11 +74,13 @@ type AgentConfig struct {
|
||||
// a key is present, so an instance with no key starts cleanly and simply
|
||||
// doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
|
||||
Enabled bool
|
||||
// VisionModel is the model that reads a photographed seed packet
|
||||
// (PANSY_VISION_MODEL). Empty by default: the seed-packet capture feature is
|
||||
// only offered when a vision-capable model is configured (in env or Settings)
|
||||
// and a key is present. Passed verbatim to majordomo.Parse, like Model.
|
||||
VisionModel string
|
||||
}
|
||||
|
||||
// Ready reports whether the assistant can actually be offered. Both the route
|
||||
// registration and whatever advertises capabilities gate on this, so what's
|
||||
// advertised always matches what's live.
|
||||
func (a AgentConfig) Ready() bool {
|
||||
return a.Enabled && a.OllamaCloudAPIKey != "" && a.Model != ""
|
||||
}
|
||||
|
||||
// Enabled reports whether enough OIDC config is present to attempt discovery.
|
||||
@@ -125,9 +127,6 @@ func Load() *Config {
|
||||
// opt-in, and making people set a second flag to use what they just
|
||||
// configured is a papercut with no upside.
|
||||
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""),
|
||||
// No default vision model: unlike chat there's no obvious safe default,
|
||||
// and the feature stays off until an admin names one that can see.
|
||||
VisionModel: envStr("PANSY_VISION_MODEL", ""),
|
||||
}
|
||||
|
||||
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
|
||||
|
||||
@@ -33,10 +33,7 @@ var (
|
||||
ErrShareExists = errors.New("garden already shared with that user")
|
||||
|
||||
// ErrInvalidInput means the caller supplied structurally invalid data (empty
|
||||
// required field, malformed value). Mapped to 400. Wrap it with the reason —
|
||||
// fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", ErrInvalidInput) —
|
||||
// and the API shows that reason to the person verbatim, so write it for
|
||||
// them, not for a log; the bare sentinel reads as just "invalid input".
|
||||
// required field, malformed value). Mapped to 400.
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
// ErrInvalidCredentials means a login attempt failed. It is deliberately
|
||||
// identical for an unknown email and a wrong password so neither can be
|
||||
@@ -245,26 +242,6 @@ const (
|
||||
ConflictUnsupported = "unsupported"
|
||||
)
|
||||
|
||||
// InstanceSettings is the single row of instance-wide, admin-editable
|
||||
// configuration (#79). Secrets are deliberately absent — see migration 0010.
|
||||
//
|
||||
// Both agent fields express "inherit from the environment unless set":
|
||||
// AgentModel == "" falls back to PANSY_AGENT_MODEL; AgentEnabled == nil inherits
|
||||
// the env default. EffectiveAgent resolves them against the environment.
|
||||
type InstanceSettings struct {
|
||||
// AgentModel overrides PANSY_AGENT_MODEL when non-empty. Passed verbatim to
|
||||
// majordomo.Parse, exactly like the env var it shadows.
|
||||
AgentModel string `json:"agentModel"`
|
||||
// AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit.
|
||||
AgentEnabled *bool `json:"agentEnabled"`
|
||||
// VisionModel overrides PANSY_VISION_MODEL when non-empty; the model that
|
||||
// reads a photographed seed packet (#81). Empty = feature off unless the env
|
||||
// var names one.
|
||||
VisionModel string `json:"visionModel"`
|
||||
Version int64 `json:"version"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
// Package imagenorm normalizes an uploaded image to a JPEG the rest of pansy
|
||||
// (and majordomo's vision path) can rely on, decoding the formats a phone
|
||||
// actually produces.
|
||||
//
|
||||
// # Why this exists
|
||||
//
|
||||
// majordomo's own media pipeline is stdlib-based, so it cannot decode HEIC or
|
||||
// WebP — and HEIC is the iPhone camera default. The seed-packet feature's very
|
||||
// first input is "a photo from my phone", so without this the feature fails on
|
||||
// the exact device that motivates it. Normalizing at the upload boundary means
|
||||
// everything downstream only ever sees JPEG.
|
||||
//
|
||||
// # The CGO constraint
|
||||
//
|
||||
// pansy is CGO_ENABLED=0 (a single static binary — the reason modernc/sqlite was
|
||||
// chosen over the C one), so a libheif *binding* is out. github.com/gen2brain/heic
|
||||
// runs libheif as WebAssembly via wazero: pure Go, no cgo, and it registers with
|
||||
// image.Decode like any other format. golang.org/x/image/webp is pure Go too.
|
||||
//
|
||||
// # Import-driven registry
|
||||
//
|
||||
// Go's image decoders register via blank imports, and the failure mode is
|
||||
// backwards from intuition: forget "image/png" and PNG uploads fail with
|
||||
// "unknown format" while the exotic HEIC still works. So the blank imports below
|
||||
// are load-bearing, and TestNormalizeAllFormats exercises all four formats to
|
||||
// keep them so.
|
||||
package imagenorm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
// Decoders, registered with image.Decode by side effect. All four matter:
|
||||
// jpeg/png are the common cases, heic is the iPhone default, webp is common
|
||||
// on the web. Dropping any one silently breaks that format's uploads.
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "github.com/gen2brain/heic"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// Defaults chosen for the seed-packet path against ollama-cloud's limits (8
|
||||
// images, 20 MiB, 2048px, jpeg+png). We re-encode to JPEG well under all of them.
|
||||
const (
|
||||
// DefaultMaxDim is the longest-edge ceiling. 2048 matches ollama-cloud's
|
||||
// MaxDim; anything larger is downscaled. A packet photo has plenty of detail
|
||||
// left at 2048.
|
||||
DefaultMaxDim = 2048
|
||||
// DefaultMaxBytes caps the *input* we will read. A phone photo is 3–8 MB; 25
|
||||
// MiB leaves headroom for a large HEIC without inviting a decompression bomb
|
||||
// as an unbounded read. The re-encoded output is far smaller.
|
||||
DefaultMaxBytes = 25 << 20
|
||||
// maxDecodePixels bounds the DECODED bitmap regardless of input byte size, so
|
||||
// a small file claiming enormous dimensions (a decompression bomb) is refused
|
||||
// before its ~4-bytes/px bitmap is allocated. 50 MP ≈ 200 MB peak — above any
|
||||
// current phone camera (a 48 MP sensor is 48 MP) while capping the
|
||||
// amplification a hostile header can force. maxDecodePixels and maxDimension
|
||||
// are internal safety floors, not knobs — unlike MaxDim/MaxBytes there's no
|
||||
// reason for a caller to raise them.
|
||||
maxDecodePixels = 50_000_000
|
||||
// maxDimension caps EACH side independently. It exists to make the pixel-count
|
||||
// check overflow-safe: without it, a header claiming ~2^32 on a side could
|
||||
// wrap int64(w)*int64(h) negative and slip past maxDecodePixels. No real image
|
||||
// is 50k px on a side.
|
||||
maxDimension = 50_000
|
||||
// jpegQuality for the normalized output. 85 is visually clean and keeps the
|
||||
// file small; the model reads text off it, not fine gradients.
|
||||
jpegQuality = 85
|
||||
)
|
||||
|
||||
// Options tunes Normalize. The zero value uses the Default* constants.
|
||||
type Options struct {
|
||||
MaxDim int // longest edge; 0 → DefaultMaxDim
|
||||
MaxBytes int // input read cap; 0 → DefaultMaxBytes
|
||||
}
|
||||
|
||||
func (o Options) maxDim() int {
|
||||
if o.MaxDim > 0 {
|
||||
return o.MaxDim
|
||||
}
|
||||
return DefaultMaxDim
|
||||
}
|
||||
|
||||
func (o Options) maxBytes() int {
|
||||
if o.MaxBytes > 0 {
|
||||
return o.MaxBytes
|
||||
}
|
||||
return DefaultMaxBytes
|
||||
}
|
||||
|
||||
// ErrTooLarge means the input exceeded the byte cap or decoded to an absurd
|
||||
// pixel count. ErrUnsupported means the bytes weren't a decodable image format —
|
||||
// or a decoder panicked on them (see the recover in Normalize).
|
||||
var (
|
||||
ErrTooLarge = errors.New("imagenorm: image too large")
|
||||
ErrUnsupported = errors.New("imagenorm: unsupported or corrupt image")
|
||||
)
|
||||
|
||||
// Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP),
|
||||
// downscales it to fit opts.MaxDim on its longest edge, applies the JPEG EXIF
|
||||
// orientation so the pixels come out upright, and returns it re-encoded as JPEG,
|
||||
// plus the decoded format name (e.g. "heic") — handy for logging what a phone
|
||||
// actually sent. On any error the returned bytes are nil and format is "".
|
||||
//
|
||||
// Errors, by cause:
|
||||
// - input over opts.MaxBytes, or a decoded canvas over maxDecodePixels /
|
||||
// maxDimension → ErrTooLarge, refused before the bitmap is allocated;
|
||||
// - bytes that aren't a decodable image, or a decoder that panics on them →
|
||||
// ErrUnsupported;
|
||||
// - a genuine read or JPEG-encode I/O failure → a wrapped error (not a
|
||||
// sentinel), since those are the caller's stream/environment, not the image.
|
||||
//
|
||||
// It bounds work against a hostile upload three ways: the byte cap, the
|
||||
// pre-decode pixel/dimension check, and a recover around the third-party decoders
|
||||
// (a malformed HEIC/WebP shouldn't take the process down).
|
||||
//
|
||||
// EXIF orientation: phone cameras store the sensor pixels in one orientation and
|
||||
// set an EXIF tag to rotate on display, so a JPEG "portrait" photo is really a
|
||||
// landscape bitmap tagged "rotate 90°" — and the re-encode below strips EXIF,
|
||||
// which is exactly why the rotation must be BAKED IN here. applyOrientation does
|
||||
// that for the JPEG path (the format phone uploads overwhelmingly arrive in);
|
||||
// other formats carry no JPEG EXIF and their decoders own orientation, so they're
|
||||
// left as decoded.
|
||||
//
|
||||
// One known gap, deferred to the upload handler (#81): there is no context —
|
||||
// image.Decode is CPU-bound and not cancellable mid-decode, so a caller that
|
||||
// needs a hard deadline should run Normalize under its own timeout. The size
|
||||
// guards keep the work finite regardless.
|
||||
func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) {
|
||||
// Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over".
|
||||
// maxBytes() is always a sane positive (default 25 MiB); guard the +1 anyway.
|
||||
byteCap := opts.maxBytes()
|
||||
limit := int64(byteCap) + 1
|
||||
if limit < 1 {
|
||||
limit = int64(DefaultMaxBytes) + 1
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r, limit))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("imagenorm: read: %w", err)
|
||||
}
|
||||
if len(raw) > byteCap {
|
||||
return nil, "", ErrTooLarge
|
||||
}
|
||||
|
||||
// Check dimensions BEFORE a full decode, so a decompression bomb is refused
|
||||
// before it allocates its bitmap. The per-side maxDimension check runs first
|
||||
// so the pixel-count multiply below can't overflow.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", ErrUnsupported
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 ||
|
||||
cfg.Width > maxDimension || cfg.Height > maxDimension ||
|
||||
int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
|
||||
return nil, "", ErrTooLarge
|
||||
}
|
||||
|
||||
img, format, err := decodeSafely(raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Downscale first (cheaper to rotate the small image), then bake in the EXIF
|
||||
// orientation so the JPEG we emit is upright. A 90° rotation swaps the sides
|
||||
// but not the longest edge, so the downscale bound still holds after it.
|
||||
img = downscale(img, opts.maxDim())
|
||||
img = applyOrientation(img, exifOrientation(raw))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||
return nil, "", fmt.Errorf("imagenorm: encode jpeg: %w", err)
|
||||
}
|
||||
return buf.Bytes(), format, nil
|
||||
}
|
||||
|
||||
// decodeSafely decodes raw, converting both a decode error and a decoder PANIC
|
||||
// into ErrUnsupported. The recover matters because the image comes from an
|
||||
// untrusted upload and the HEIC/WebP decoders are third-party (libheif via WASM,
|
||||
// x/image/webp): a malformed file that panics one of them must fail this one
|
||||
// request, not crash the process.
|
||||
func decodeSafely(raw []byte) (img image.Image, format string, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
img, format, err = nil, "", ErrUnsupported
|
||||
}
|
||||
}()
|
||||
img, format, err = image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", ErrUnsupported
|
||||
}
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// applyOrientation returns img with the EXIF orientation (1..8) baked in, so the
|
||||
// pixels are upright and no display-time rotation is needed. Orientation 1 (and
|
||||
// anything out of range) is a no-op. Values 5..8 are 90° rotations, which swap
|
||||
// the output's width and height. Copies raw RGBA pixels by byte offset (after a
|
||||
// one-time conversion if the source isn't already RGBA), so a full-resolution
|
||||
// rotation doesn't box a color.Color per pixel.
|
||||
func applyOrientation(img image.Image, o int) image.Image {
|
||||
if o <= 1 || o > 8 {
|
||||
return img
|
||||
}
|
||||
// Work on a concrete RGBA so the transform is a 4-byte copy per pixel rather
|
||||
// than millions of boxed color.Color values through At/Set. downscale usually
|
||||
// hands us an *image.RGBA already; convert once if not (e.g. a small JPEG that
|
||||
// skipped downscale decodes to YCbCr).
|
||||
src, ok := img.(*image.RGBA)
|
||||
if !ok {
|
||||
b := img.Bounds()
|
||||
conv := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(conv, conv.Bounds(), img, b.Min, draw.Src)
|
||||
src = conv
|
||||
}
|
||||
sb := src.Bounds()
|
||||
w, h := sb.Dx(), sb.Dy()
|
||||
// A quarter-turn (5..8) transposes the output; size the one buffer accordingly.
|
||||
dw, dh := w, h
|
||||
if o >= 5 {
|
||||
dw, dh = h, w
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
var dx, dy int
|
||||
switch o {
|
||||
case 2: // mirror horizontal
|
||||
dx, dy = w-1-x, y
|
||||
case 3: // rotate 180
|
||||
dx, dy = w-1-x, h-1-y
|
||||
case 4: // mirror vertical
|
||||
dx, dy = x, h-1-y
|
||||
case 5: // transpose (mirror across the main diagonal)
|
||||
dx, dy = y, x
|
||||
case 6: // rotate 90° clockwise
|
||||
dx, dy = h-1-y, x
|
||||
case 7: // transverse (mirror across the anti-diagonal)
|
||||
dx, dy = h-1-y, w-1-x
|
||||
case 8: // rotate 90° counter-clockwise
|
||||
dx, dy = y, w-1-x
|
||||
}
|
||||
si := src.PixOffset(sb.Min.X+x, sb.Min.Y+y)
|
||||
di := dst.PixOffset(dx, dy)
|
||||
copy(dst.Pix[di:di+4], src.Pix[si:si+4])
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// exifOrientation extracts the EXIF Orientation tag (1..8) from raw image bytes,
|
||||
// returning 1 (normal) when it's absent or unparseable — the safe default, since
|
||||
// a wrong guess rotates a correct image. Only the JPEG APP1/Exif path is parsed:
|
||||
// that's the format uploaded phone photos overwhelmingly arrive in, and the other
|
||||
// decoders own their own orientation.
|
||||
func exifOrientation(raw []byte) int {
|
||||
// A JPEG is a run of FFxx marker segments after the SOI (FFD8). Walk them
|
||||
// looking for APP1 (FFE1) carrying "Exif\0\0"; stop at the scan data (SOS).
|
||||
if len(raw) < 4 || raw[0] != 0xFF || raw[1] != 0xD8 {
|
||||
return 1
|
||||
}
|
||||
for i := 2; i+1 < len(raw); {
|
||||
if raw[i] != 0xFF {
|
||||
return 1 // not aligned on a marker; give up rather than misread
|
||||
}
|
||||
// A marker may be preceded by any number of 0xFF fill bytes (JPEG spec);
|
||||
// skip them so a padded APP1 isn't misread as a marker of value 0xFF.
|
||||
for i+1 < len(raw) && raw[i+1] == 0xFF {
|
||||
i++
|
||||
}
|
||||
if i+1 >= len(raw) {
|
||||
return 1
|
||||
}
|
||||
marker := raw[i+1]
|
||||
if marker == 0xD9 || marker == 0xDA {
|
||||
return 1 // EOI / start-of-scan: no more headers to read
|
||||
}
|
||||
if i+4 > len(raw) {
|
||||
return 1
|
||||
}
|
||||
segLen := int(raw[i+2])<<8 | int(raw[i+3])
|
||||
if segLen < 2 || i+2+segLen > len(raw) {
|
||||
return 1
|
||||
}
|
||||
if marker == 0xE1 {
|
||||
if o, ok := orientationFromApp1(raw[i+4 : i+2+segLen]); ok {
|
||||
return o
|
||||
}
|
||||
}
|
||||
i += 2 + segLen
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// orientationFromApp1 reads the Orientation tag from a JPEG APP1 segment body
|
||||
// (everything after the 2-byte length): "Exif\0\0" then a TIFF block holding
|
||||
// IFD0. Returns (0, false) if the segment isn't Exif or the tag is missing.
|
||||
func orientationFromApp1(seg []byte) (int, bool) {
|
||||
const prefix = "Exif\x00\x00"
|
||||
if len(seg) < len(prefix)+8 || string(seg[:len(prefix)]) != prefix {
|
||||
return 0, false
|
||||
}
|
||||
tiff := seg[len(prefix):]
|
||||
var bo binary.ByteOrder
|
||||
switch string(tiff[0:2]) {
|
||||
case "II":
|
||||
bo = binary.LittleEndian
|
||||
case "MM":
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
if bo.Uint16(tiff[2:4]) != 0x2A { // TIFF magic (42); byte order must agree
|
||||
return 0, false
|
||||
}
|
||||
ifd := int(bo.Uint32(tiff[4:8])) // offset to IFD0 from the TIFF start
|
||||
if ifd < 8 || ifd+2 > len(tiff) {
|
||||
return 0, false
|
||||
}
|
||||
n := int(bo.Uint16(tiff[ifd : ifd+2]))
|
||||
for k := range n {
|
||||
off := ifd + 2 + k*12 // each IFD entry is 12 bytes
|
||||
if off+12 > len(tiff) {
|
||||
return 0, false
|
||||
}
|
||||
if bo.Uint16(tiff[off:off+2]) != 0x0112 { // Orientation tag
|
||||
continue
|
||||
}
|
||||
// Orientation is defined as a single SHORT, whose value sits inline in the
|
||||
// first 2 bytes of the value field. Reject anything else rather than read a
|
||||
// mistyped entry (a LONG/offset there would be a different number entirely).
|
||||
if bo.Uint16(tiff[off+2:off+4]) != 3 || bo.Uint32(tiff[off+4:off+8]) != 1 {
|
||||
return 0, false
|
||||
}
|
||||
v := int(bo.Uint16(tiff[off+8 : off+10]))
|
||||
if v >= 1 && v <= 8 {
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// downscale returns img shrunk so its longest edge is at most maxDim, preserving
|
||||
// aspect ratio. An image already within bounds is returned unchanged (no
|
||||
// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for
|
||||
// a sharp result on text, which is what a packet photo is mostly made of.
|
||||
func downscale(img image.Image, maxDim int) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
longest := max(w, h)
|
||||
if longest <= maxDim || longest == 0 {
|
||||
return img
|
||||
}
|
||||
scale := float64(maxDim) / float64(longest)
|
||||
nw, nh := max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1)
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package imagenorm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngBytes and jpegBytes generate in-memory fixtures for the two formats Go can
|
||||
// encode; heic/webp come from testdata (Go has no encoder for them).
|
||||
func pngBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
m.Pix[m.PixOffset(x, y)+0] = uint8(x)
|
||||
m.Pix[m.PixOffset(x, y)+3] = 255
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := png.Encode(&b, m); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func jpegBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, m, nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func readTestdata(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestNormalizeAllFormats is the load-bearing test: every format pansy claims to
|
||||
// accept must round-trip to a valid JPEG. It exists specifically to catch a
|
||||
// dropped blank import — the failure mode where the common format (PNG) breaks
|
||||
// while the exotic one (HEIC) works, because someone deleted `_ "image/png"`.
|
||||
func TestNormalizeAllFormats(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input []byte
|
||||
wantFormat string
|
||||
}{
|
||||
{"png", pngBytes(t, 120, 90), "png"},
|
||||
{"jpeg", jpegBytes(t, 120, 90), "jpeg"},
|
||||
{"heic", readTestdata(t, "sample.heic"), "heic"},
|
||||
{"webp", readTestdata(t, "sample.webp"), "webp"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, format, err := Normalize(bytes.NewReader(tc.input), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize(%s): %v", tc.name, err)
|
||||
}
|
||||
if format != tc.wantFormat {
|
||||
t.Errorf("format = %q, want %q", format, tc.wantFormat)
|
||||
}
|
||||
// The output must itself be a decodable JPEG.
|
||||
_, outFormat, err := image.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("output isn't a valid image: %v", err)
|
||||
}
|
||||
if outFormat != "jpeg" {
|
||||
t.Errorf("output format = %q, want jpeg", outFormat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeDownscales checks a large image is shrunk to fit MaxDim on its
|
||||
// longest edge with aspect ratio preserved, and a small one is left alone.
|
||||
func TestNormalizeDownscales(t *testing.T) {
|
||||
// A 2:1 image twice as wide as DefaultMaxDim → clamped to DefaultMaxDim on the
|
||||
// long edge with aspect preserved. Derived from the constant, not hard-coded,
|
||||
// so the test tracks the default rather than silently asserting a magic number.
|
||||
longEdge := DefaultMaxDim * 2
|
||||
big := pngBytes(t, longEdge, longEdge/2)
|
||||
out, _, err := Normalize(bytes.NewReader(big), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize: %v", err)
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode out: %v", err)
|
||||
}
|
||||
if cfg.Width != DefaultMaxDim {
|
||||
t.Errorf("width = %d, want %d (longest edge clamped)", cfg.Width, DefaultMaxDim)
|
||||
}
|
||||
if cfg.Height != DefaultMaxDim/2 {
|
||||
t.Errorf("height = %d, want %d (aspect preserved)", cfg.Height, DefaultMaxDim/2)
|
||||
}
|
||||
|
||||
// A small image within bounds keeps its dimensions.
|
||||
small := pngBytes(t, 100, 80)
|
||||
out, _, err = Normalize(bytes.NewReader(small), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize small: %v", err)
|
||||
}
|
||||
cfg, _, err = image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode small out: %v", err)
|
||||
}
|
||||
if cfg.Width != 100 || cfg.Height != 80 {
|
||||
t.Errorf("small image resized to %dx%d, want 100x80", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeRejectsOversizeInput: an input past the byte cap is ErrTooLarge,
|
||||
// refused without a full decode.
|
||||
func TestNormalizeRejectsOversizeInput(t *testing.T) {
|
||||
big := pngBytes(t, 500, 500)
|
||||
_, _, err := Normalize(bytes.NewReader(big), Options{MaxBytes: 100})
|
||||
if err != ErrTooLarge {
|
||||
t.Errorf("over-cap input err = %v, want ErrTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeRejectsGarbage: unreadable-as-image bytes and a truncated image
|
||||
// both fail cleanly with ErrUnsupported, not a panic.
|
||||
func TestNormalizeRejectsGarbage(t *testing.T) {
|
||||
_, _, err := Normalize(bytes.NewReader([]byte("not an image at all")), Options{})
|
||||
if err != ErrUnsupported {
|
||||
t.Errorf("garbage err = %v, want ErrUnsupported", err)
|
||||
}
|
||||
// A truncated image (valid header, cut body) also fails cleanly, not a panic.
|
||||
png := pngBytes(t, 100, 100)
|
||||
_, _, err = Normalize(bytes.NewReader(png[:len(png)/2]), Options{})
|
||||
if err != ErrUnsupported {
|
||||
t.Errorf("truncated image err = %v, want ErrUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pngHeader builds a valid PNG signature + IHDR chunk (with a correct CRC, which
|
||||
// DecodeConfig verifies) for the given dimensions, and nothing else. It's enough
|
||||
// for image.DecodeConfig to report width/height without a real bitmap — exactly
|
||||
// what's needed to exercise the pre-decode size guard with a tiny input.
|
||||
func pngHeader(w, h uint32) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
|
||||
ihdr := make([]byte, 13)
|
||||
binary.BigEndian.PutUint32(ihdr[0:], w)
|
||||
binary.BigEndian.PutUint32(ihdr[4:], h)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 6 // colour type: RGBA
|
||||
// compression/filter/interlace already 0.
|
||||
binary.Write(&buf, binary.BigEndian, uint32(len(ihdr)))
|
||||
chunk := append([]byte("IHDR"), ihdr...)
|
||||
buf.Write(chunk)
|
||||
binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestNormalizeRejectsPixelBomb is the guard the review found untested: a small
|
||||
// input (a bare ~40-byte PNG header) claiming an enormous canvas is refused with
|
||||
// ErrTooLarge from DecodeConfig alone, before image.Decode allocates anything.
|
||||
// Covers both the per-side maxDimension trip and the pixel-count trip — and, via
|
||||
// the near-2^16-per-side case, that the count math doesn't overflow.
|
||||
func TestNormalizeRejectsPixelBomb(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h uint32
|
||||
}{
|
||||
{"huge single side", 60000, 10}, // > maxDimension on width
|
||||
{"huge area within side cap", 40000, 40000}, // sides < cap, area 1.6 GP > maxDecodePixels
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
hdr := pngHeader(tc.w, tc.h)
|
||||
if len(hdr) > 100 {
|
||||
t.Fatalf("header unexpectedly large (%d bytes) — not a bomb test", len(hdr))
|
||||
}
|
||||
// Sanity: the header really does decode to those dimensions.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(hdr))
|
||||
if err != nil {
|
||||
t.Fatalf("crafted PNG header didn't parse: %v", err)
|
||||
}
|
||||
if uint32(cfg.Width) != tc.w || uint32(cfg.Height) != tc.h {
|
||||
t.Fatalf("header reports %dx%d, want %dx%d", cfg.Width, cfg.Height, tc.w, tc.h)
|
||||
}
|
||||
if _, _, err := Normalize(bytes.NewReader(hdr), Options{}); err != ErrTooLarge {
|
||||
t.Errorf("pixel bomb %dx%d err = %v, want ErrTooLarge", tc.w, tc.h, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// orientedJPEG builds a JPEG whose top-left quadrant is white and the rest black
|
||||
// — a marker to track through a rotation — tagged with the given EXIF orientation
|
||||
// (1..8). The marker lets a test assert the pixels actually moved to where that
|
||||
// orientation says they should.
|
||||
func orientedJPEG(t *testing.T, orient int) []byte {
|
||||
t.Helper()
|
||||
const w, h = 40, 24
|
||||
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
c := color.RGBA{0, 0, 0, 255}
|
||||
if x < w/2 && y < h/2 {
|
||||
c = color.RGBA{255, 255, 255, 255}
|
||||
}
|
||||
m.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var jb bytes.Buffer
|
||||
if err := jpeg.Encode(&jb, m, &jpeg.Options{Quality: 95}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
if orient == 0 {
|
||||
return jb.Bytes() // caller wants a plain JPEG with no EXIF
|
||||
}
|
||||
return spliceExifOrientation(t, jb.Bytes(), orient)
|
||||
}
|
||||
|
||||
// spliceExifOrientation inserts a minimal little-endian Exif APP1 segment
|
||||
// carrying just the Orientation tag right after the JPEG SOI marker.
|
||||
func spliceExifOrientation(t *testing.T, jpg []byte, orient int) []byte {
|
||||
t.Helper()
|
||||
var tiff bytes.Buffer
|
||||
tiff.WriteString("II") // little-endian
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x2A))
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint32(8)) // IFD0 offset
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(1)) // one entry
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x0112))
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(3)) // SHORT
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint32(1)) // count
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(orient)) // value
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0)) // value pad
|
||||
_ = binary.Write(&tiff, binary.LittleEndian, uint32(0)) // next IFD
|
||||
payload := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
|
||||
|
||||
segLen := len(payload) + 2
|
||||
seg := []byte{0xFF, 0xE1, byte(segLen >> 8), byte(segLen)}
|
||||
seg = append(seg, payload...)
|
||||
|
||||
out := make([]byte, 0, len(jpg)+len(seg))
|
||||
out = append(out, jpg[:2]...) // SOI
|
||||
out = append(out, seg...)
|
||||
return append(out, jpg[2:]...)
|
||||
}
|
||||
|
||||
func bright(c color.Color) bool {
|
||||
r, g, b, _ := c.RGBA() // 16-bit
|
||||
return (r+g+b)/3 > 0x8000
|
||||
}
|
||||
|
||||
// TestNormalizeAppliesExifOrientation is the #103 regression: a phone photo tagged
|
||||
// "rotate 90°" must come out of Normalize with the pixels upright, not sideways —
|
||||
// the re-encode strips EXIF, so the rotation has to be baked into the bitmap.
|
||||
func TestNormalizeAppliesExifOrientation(t *testing.T) {
|
||||
// The white marker starts centred at (10,6) in the 40x24 source. For each
|
||||
// orientation, wantW/H is the corrected canvas and (mx,my) is where that
|
||||
// marker must land — derived from the same transform Normalize applies.
|
||||
cases := []struct {
|
||||
name string
|
||||
orient int
|
||||
wantW, wantH int
|
||||
mx, my int
|
||||
}{
|
||||
{"none", 0, 40, 24, 10, 6}, // no EXIF → unchanged, marker top-left
|
||||
{"normal", 1, 40, 24, 10, 6}, // normal → unchanged
|
||||
{"mirror-h", 2, 40, 24, 29, 6}, // flip horizontal → top-right
|
||||
{"rotate-180", 3, 40, 24, 29, 17}, // → bottom-right
|
||||
{"mirror-v", 4, 40, 24, 10, 17}, // flip vertical → bottom-left
|
||||
{"transpose", 5, 24, 40, 6, 10}, // main diagonal (dims swap)
|
||||
{"rotate-90-cw", 6, 24, 40, 17, 10}, // → top-right (dims swap)
|
||||
{"transverse", 7, 24, 40, 17, 29}, // anti-diagonal (dims swap)
|
||||
{"rotate-90-ccw", 8, 24, 40, 6, 29}, // → bottom-left (dims swap)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, format, err := Normalize(bytes.NewReader(orientedJPEG(t, tc.orient)), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize err = %v", err)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Errorf("format = %q, want jpeg", format)
|
||||
}
|
||||
m, _, err := image.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode output: %v", err)
|
||||
}
|
||||
if m.Bounds().Dx() != tc.wantW || m.Bounds().Dy() != tc.wantH {
|
||||
t.Errorf("output %dx%d, want %dx%d",
|
||||
m.Bounds().Dx(), m.Bounds().Dy(), tc.wantW, tc.wantH)
|
||||
}
|
||||
if !bright(m.At(m.Bounds().Min.X+tc.mx, m.Bounds().Min.Y+tc.my)) {
|
||||
t.Errorf("white marker not at (%d,%d) — orientation not applied", tc.mx, tc.my)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExifOrientationParsing pins the parser against non-JPEG and no-EXIF inputs,
|
||||
// which must default to 1 (never guess a rotation onto a correct image).
|
||||
func TestExifOrientationParsing(t *testing.T) {
|
||||
if o := exifOrientation(pngBytes(t, 8, 8)); o != 1 {
|
||||
t.Errorf("PNG orientation = %d, want 1 (no JPEG EXIF path)", o)
|
||||
}
|
||||
if o := exifOrientation(orientedJPEG(t, 0)); o != 1 {
|
||||
t.Errorf("JPEG without EXIF orientation = %d, want 1", o)
|
||||
}
|
||||
if o := exifOrientation(orientedJPEG(t, 6)); o != 6 {
|
||||
t.Errorf("JPEG tagged 6 → %d, want 6", o)
|
||||
}
|
||||
if o := exifOrientation([]byte("not an image")); o != 1 {
|
||||
t.Errorf("garbage bytes → %d, want 1", o)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
# imagenorm test fixtures
|
||||
|
||||
Go has no encoder for HEIC or WebP, so these small samples are committed rather
|
||||
than generated at test time. They are used by `TestNormalizeAllFormats` to prove
|
||||
every accepted format round-trips to JPEG (and, mainly, to catch a dropped blank
|
||||
import — see the package doc).
|
||||
|
||||
- `sample.heic` — a 240×160 gradient, created from a Go-generated PNG with macOS
|
||||
`sips -s format heic`. HEVC still-image profile.
|
||||
- `sample.webp` — a 16×16 image copied from CPython's stdlib test corpus
|
||||
(`Lib/test/test_email/data/python.webp`), verified to decode with
|
||||
`golang.org/x/image/webp` before committing. Used only as a decode fixture.
|
||||
|
||||
Neither contains anything meaningful; they exist purely to be decoded.
|
||||
|
Before Width: | Height: | Size: 432 B |
@@ -1,182 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// Instance settings (#79): admin-editable, instance-wide configuration. The
|
||||
// admin gate lives HERE, in the seam, not in the handler — the same rule every
|
||||
// other permission follows. The API's requireAdmin middleware is a cheap early
|
||||
// 403, not the authority.
|
||||
|
||||
// requireAdmin returns nil iff the actor is an admin, else ErrForbidden.
|
||||
//
|
||||
// ErrForbidden, not ErrNotFound: settings are not a resource whose existence is
|
||||
// masked. A logged-in non-admin knows the instance has settings; they simply may
|
||||
// not touch them. (Contrast objects/lots, where no-access masks existence.)
|
||||
func (s *Service) requireAdmin(ctx context.Context, actorID int64) error {
|
||||
u, err := s.store.GetUserByID(ctx, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceSettings returns the instance settings for an admin.
|
||||
func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*domain.InstanceSettings, error) {
|
||||
if err := s.requireAdmin(ctx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.GetInstanceSettings(ctx)
|
||||
}
|
||||
|
||||
// InstanceSettingsPatch is a full replacement of the editable fields plus the
|
||||
// current version. Both agent fields carry their "inherit" sentinel: an empty
|
||||
// AgentModel means fall back to env, a nil AgentEnabled means inherit.
|
||||
type InstanceSettingsPatch struct {
|
||||
AgentModel string
|
||||
AgentEnabled *bool
|
||||
VisionModel string
|
||||
Version int64
|
||||
}
|
||||
|
||||
// UpdateInstanceSettings applies an admin's change, version-guarded. It returns
|
||||
// (current row, ErrVersionConflict) on a stale version, like every mutable
|
||||
// resource. The model spec is validated before it is stored, so a typo is a 400
|
||||
// now rather than a broken assistant on the next turn.
|
||||
//
|
||||
// It does NOT rebuild the running agent — that is the API layer's job, because
|
||||
// the live Runner lives there. The caller rebuilds on success.
|
||||
func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, patch InstanceSettingsPatch) (*domain.InstanceSettings, error) {
|
||||
if err := s.requireAdmin(ctx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model := strings.TrimSpace(patch.AgentModel)
|
||||
vision := strings.TrimSpace(patch.VisionModel)
|
||||
// Validate non-empty specs up front. An empty one is the "inherit env"
|
||||
// sentinel and needs no check — the env value was validated at boot. The
|
||||
// reason rides on the sentinel so the 400 can show it: "unknown provider"
|
||||
// is something a person can act on, "invalid input" is not.
|
||||
for _, f := range []struct{ label, spec string }{{"chat model", model}, {"vision model", vision}} {
|
||||
if f.spec == "" {
|
||||
continue
|
||||
}
|
||||
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, f.spec); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s %q: %v", domain.ErrInvalidInput, f.label, f.spec, specReason(err))
|
||||
}
|
||||
}
|
||||
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
||||
AgentModel: model,
|
||||
AgentEnabled: patch.AgentEnabled,
|
||||
VisionModel: vision,
|
||||
Version: patch.Version,
|
||||
})
|
||||
}
|
||||
|
||||
// EffectiveAgent resolves the agent configuration actually in force: DB settings
|
||||
// override the environment, and the API key always comes from the environment.
|
||||
//
|
||||
// This is internal plumbing (the API layer calls it to build the Runner), NOT an
|
||||
// admin-gated operation — resolving what's configured is not the same as editing
|
||||
// it, and the agent bootstrap must work before any user is even authenticated.
|
||||
type EffectiveAgent struct {
|
||||
Model string
|
||||
Enabled bool
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// Ready mirrors config.AgentConfig.Ready: enabled, with a key and a model.
|
||||
func (e EffectiveAgent) Ready() bool {
|
||||
return e.Enabled && e.APIKey != "" && e.Model != ""
|
||||
}
|
||||
|
||||
// EffectiveAgent reads the settings row and layers it over the environment.
|
||||
func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
|
||||
st, err := s.store.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return EffectiveAgent{}, err
|
||||
}
|
||||
return s.agentOver(st), nil
|
||||
}
|
||||
|
||||
// agentOver layers a settings row over the env-derived agent defaults. Split out
|
||||
// so EffectiveConfig can resolve agent AND vision from a single row read instead
|
||||
// of fetching the same one-row table twice.
|
||||
func (s *Service) agentOver(st *domain.InstanceSettings) EffectiveAgent {
|
||||
eff := EffectiveAgent{
|
||||
Model: s.cfg.Agent.Model,
|
||||
Enabled: s.cfg.Agent.Enabled,
|
||||
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
||||
}
|
||||
if st.AgentModel != "" {
|
||||
eff.Model = st.AgentModel
|
||||
}
|
||||
if st.AgentEnabled != nil {
|
||||
eff.Enabled = *st.AgentEnabled
|
||||
}
|
||||
return eff
|
||||
}
|
||||
|
||||
// EffectiveVision resolves the vision configuration in force for seed-packet
|
||||
// capture (#81): the model from DB-over-env, the key always from env.
|
||||
type EffectiveVision struct {
|
||||
Model string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// Ready reports whether packet capture can be offered: a key and a vision model.
|
||||
// There's no separate enabled flag — configuring a vision model IS enabling it.
|
||||
func (e EffectiveVision) Ready() bool {
|
||||
return e.APIKey != "" && e.Model != ""
|
||||
}
|
||||
|
||||
// EffectiveVision reads the settings row and layers it over the environment.
|
||||
func (s *Service) EffectiveVision(ctx context.Context) (EffectiveVision, error) {
|
||||
st, err := s.store.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return EffectiveVision{}, err
|
||||
}
|
||||
return s.visionOver(st), nil
|
||||
}
|
||||
|
||||
// visionOver layers a settings row over the env-derived vision defaults. See
|
||||
// agentOver for why this is split from EffectiveVision.
|
||||
func (s *Service) visionOver(st *domain.InstanceSettings) EffectiveVision {
|
||||
eff := EffectiveVision{
|
||||
Model: s.cfg.Agent.VisionModel,
|
||||
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
||||
}
|
||||
if st.VisionModel != "" {
|
||||
eff.Model = st.VisionModel
|
||||
}
|
||||
return eff
|
||||
}
|
||||
|
||||
// EffectiveConfig resolves the agent AND vision configuration from ONE settings
|
||||
// read, for callers (the settings view) that need both — the single-row table
|
||||
// would otherwise be fetched twice for one response.
|
||||
func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, EffectiveVision, error) {
|
||||
st, err := s.store.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return EffectiveAgent{}, EffectiveVision{}, err
|
||||
}
|
||||
return s.agentOver(st), s.visionOver(st), nil
|
||||
}
|
||||
|
||||
// specReason strips agentmodel's own "resolve %q:" wrapping so the message
|
||||
// reads "unknown provider …" rather than repeating the spec twice.
|
||||
func specReason(err error) string {
|
||||
if u := errors.Unwrap(err); u != nil {
|
||||
return u.Error()
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// settingsTestService builds a service whose env config carries the given agent
|
||||
// model/enabled/key, so EffectiveAgent's env fallback can be exercised.
|
||||
func settingsTestService(t *testing.T, envModel string, envEnabled bool, key string) (*Service, int64) {
|
||||
t.Helper()
|
||||
cfg := openConfig()
|
||||
cfg.Agent = config.AgentConfig{Model: envModel, Enabled: envEnabled, OllamaCloudAPIKey: key}
|
||||
s := newTestService(t, cfg)
|
||||
admin := seedUser(t, s, "[email protected]") // first user is admin
|
||||
return s, admin
|
||||
}
|
||||
|
||||
// TestRequireAdmin: the first user is admin; a second is not and gets
|
||||
// ErrForbidden (not ErrNotFound — settings existence isn't masked).
|
||||
func TestRequireAdmin(t *testing.T) {
|
||||
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||
member := seedUser(t, s, "[email protected]")
|
||||
|
||||
if err := s.requireAdmin(context.Background(), admin); err != nil {
|
||||
t.Errorf("admin rejected: %v", err)
|
||||
}
|
||||
if err := s.requireAdmin(context.Background(), member); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Errorf("member requireAdmin = %v, want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveAgentLayering: DB settings override env; the API key always comes
|
||||
// from env; the "inherit" sentinels fall back.
|
||||
func TestEffectiveAgentLayering(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, admin := settingsTestService(t, "ollama-cloud/env-model", true, "envkey")
|
||||
|
||||
// Untouched: everything inherits env.
|
||||
eff, err := s.EffectiveAgent(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("effective: %v", err)
|
||||
}
|
||||
if eff.Model != "ollama-cloud/env-model" || !eff.Enabled || eff.APIKey != "envkey" {
|
||||
t.Errorf("inherited effective = %+v, want the env values", eff)
|
||||
}
|
||||
|
||||
// Override the model only; enabled still inherits env (true).
|
||||
cur, _ := s.GetInstanceSettings(ctx, admin)
|
||||
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||
AgentModel: "ollama-cloud/glm-5.2:cloud", Version: cur.Version,
|
||||
}); err != nil {
|
||||
t.Fatalf("update model: %v", err)
|
||||
}
|
||||
eff, _ = s.EffectiveAgent(ctx)
|
||||
if eff.Model != "ollama-cloud/glm-5.2:cloud" {
|
||||
t.Errorf("model = %q, want the DB override", eff.Model)
|
||||
}
|
||||
if !eff.Enabled {
|
||||
t.Error("enabled should still inherit env (true) when unset")
|
||||
}
|
||||
|
||||
// Now override enabled to false explicitly.
|
||||
cur, _ = s.GetInstanceSettings(ctx, admin)
|
||||
no := false
|
||||
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||
AgentModel: "ollama-cloud/glm-5.2:cloud", AgentEnabled: &no, Version: cur.Version,
|
||||
}); err != nil {
|
||||
t.Fatalf("update enabled: %v", err)
|
||||
}
|
||||
eff, _ = s.EffectiveAgent(ctx)
|
||||
if eff.Enabled {
|
||||
t.Error("enabled should be the explicit false override now")
|
||||
}
|
||||
if eff.Ready() {
|
||||
t.Error("Ready() should be false when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateInstanceSettingsRejectsBadModel: a spec that won't resolve is
|
||||
// ErrInvalidInput, before it is stored.
|
||||
func TestUpdateInstanceSettingsRejectsBadModel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||
cur, _ := s.GetInstanceSettings(ctx, admin)
|
||||
|
||||
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||
AgentModel: "nonesuch/model", Version: cur.Version,
|
||||
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("bad model = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// The rejected write didn't touch the row.
|
||||
after, _ := s.GetInstanceSettings(ctx, admin)
|
||||
if after.Version != cur.Version || after.AgentModel != "" {
|
||||
t.Errorf("a rejected update changed the row: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstanceSettingsAdminGate: the read/write operations are admin-gated at the
|
||||
// service seam, not just in the handler.
|
||||
func TestInstanceSettingsAdminGate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||
member := seedUser(t, s, "[email protected]")
|
||||
|
||||
if _, err := s.GetInstanceSettings(ctx, member); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Errorf("member GetInstanceSettings = %v, want ErrForbidden", err)
|
||||
}
|
||||
if _, err := s.UpdateInstanceSettings(ctx, member, InstanceSettingsPatch{Version: 1}); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Errorf("member UpdateInstanceSettings = %v, want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,13 @@ type Region struct {
|
||||
MinX, MinY, MaxX, MaxY float64
|
||||
}
|
||||
|
||||
// contains reports whether a local point lies in the region.
|
||||
func (r Region) contains(x, y float64) bool {
|
||||
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
|
||||
}
|
||||
|
||||
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
|
||||
// so a fill can't plant outside the object it was aimed at. A region that misses
|
||||
// the object entirely comes back empty — see empty().
|
||||
// so an oversized caller-supplied region can't make hexCenters loop forever.
|
||||
func (r Region) clampTo(halfW, halfH float64) Region {
|
||||
return Region{
|
||||
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
|
||||
@@ -38,16 +42,6 @@ func (r Region) clampTo(halfW, halfH float64) Region {
|
||||
}
|
||||
}
|
||||
|
||||
// empty reports whether the region encloses nothing.
|
||||
//
|
||||
// This exists because clampTo expresses "no overlap" by INVERTING the region —
|
||||
// Max clamps below Min — rather than by zeroing it, which is not something a
|
||||
// reader guesses. Naming it once here beats a bare `MaxX < MinX` at each place
|
||||
// that has to care.
|
||||
func (r Region) empty() bool {
|
||||
return r.MaxX < r.MinX || r.MaxY < r.MinY
|
||||
}
|
||||
|
||||
// rect builds a rectangular region.
|
||||
func rect(minX, minY, maxX, maxY float64) Region {
|
||||
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
|
||||
@@ -97,111 +91,28 @@ func defaultPlopRadius(spacingCM float64) float64 {
|
||||
return math.Max(1.5*spacingCM, 15)
|
||||
}
|
||||
|
||||
// FillLayout selects what a fill packs (#77).
|
||||
//
|
||||
// A plop is a CLUMP, not a plant, and that abstraction is the right primitive for
|
||||
// SKETCHING — "a few plops of garlic in one corner" — but it can't draw a real
|
||||
// planting: a filled 4×8ft bed comes out as ~15 blobs, not 8 rows of garlic. So
|
||||
// filling is now two operations. FillClump (the default, unchanged) drops fat
|
||||
// clumps for quick coverage; FillGrid lays out individual plants at true spacing,
|
||||
// producing a layout you could actually plant from.
|
||||
type FillLayout string
|
||||
|
||||
const (
|
||||
// FillClump packs fat clumps (radius 1.5×spacing). Each plop is ~7 plants.
|
||||
FillClump FillLayout = "clump"
|
||||
// FillGrid packs one plant per plop at true spacing (radius spacing/2, pitch
|
||||
// = spacing). A bed becomes rows of individual plants.
|
||||
FillGrid FillLayout = "grid"
|
||||
)
|
||||
|
||||
// plopRadiusFor is the plop radius a fill uses, given the plant's spacing and the
|
||||
// layout. Grid mode is a plain spacing/2 (so the pitch is one spacing and each
|
||||
// plop's derived count is 1); clump mode keeps the 15cm floor that stops a
|
||||
// tiny-spacing plant from making invisibly small clumps — a floor grid mode
|
||||
// doesn't want, since its whole point is true spacing.
|
||||
func plopRadiusFor(spacingCM float64, layout FillLayout) float64 {
|
||||
if layout == FillGrid {
|
||||
return spacingCM / 2
|
||||
}
|
||||
return defaultPlopRadius(spacingCM)
|
||||
}
|
||||
|
||||
// edgeInset is how far a plop's CENTRE must stay inside the region edge. It
|
||||
// differs by layout because the half-spacing rule is about where the PLANT lands,
|
||||
// and the plant sits in a different place within the plop.
|
||||
//
|
||||
// Spacing is a constraint between neighbouring plants competing for the same soil,
|
||||
// light and water; a bed edge is nobody's neighbour, so the outer plant owes it
|
||||
// only HALF the spacing — the half it would otherwise share. That is the
|
||||
// square-foot-chart arithmetic: garlic at 9-per-square sits 2" from the frame, not
|
||||
// 6".
|
||||
//
|
||||
// - Grid: one plant, at the plop's centre. Put that centre a half-spacing in and
|
||||
// the outer row lands exactly where the rule wants it — inset = spacing/2.
|
||||
// - Clump: a fat plop (radius 1.5×spacing) whose plants fill out to its RIM.
|
||||
// Insetting the whole circle would push the outer row a full 1.5 spacings in,
|
||||
// three times the rule. Instead the clump may hang over by a half-spacing (rim
|
||||
// at spacing/2 past the edge), landing its outermost plants that same
|
||||
// half-spacing in — inset = radius − spacing/2. A grid plop reusing THAT
|
||||
// formula would inset by radius − spacing/2 = 0 and plant flush on the edge,
|
||||
// which is the bug this split fixes.
|
||||
func edgeInset(radius, spacing float64, layout FillLayout) float64 {
|
||||
half := math.Max(0, spacing) / 2
|
||||
if layout == FillGrid {
|
||||
return half
|
||||
}
|
||||
return math.Max(0, radius-half)
|
||||
}
|
||||
|
||||
// validFillLayout normalizes a layout: empty defaults to clump (so existing
|
||||
// callers are unchanged), a known value passes, anything else is rejected.
|
||||
func validFillLayout(l FillLayout) (FillLayout, bool) {
|
||||
switch l {
|
||||
case "", FillClump:
|
||||
return FillClump, true
|
||||
case FillGrid:
|
||||
return FillGrid, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// FillRegion lays a field of plops of one plant across a region of a plantable
|
||||
// object the actor can edit. The layout picks the primitive: FillClump drops fat
|
||||
// clumps for quick sketching, FillGrid lays out individual plants at true spacing
|
||||
// (see FillLayout). Plop radius comes from the plant's spacing (or spacingOverride)
|
||||
// via plopRadiusFor; centers sit on a centered hex lattice at 2×radius pitch, set
|
||||
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
|
||||
// spacing for a clump (see edgeInset for the why). A candidate is skipped when its
|
||||
// plop would sit entirely inside an existing active plop (so re-filling doesn't
|
||||
// stack duplicates). Every plop is dated plantedAt (YYYY-MM-DD), or UTC today
|
||||
// when nil — the UI always sends its local day, so the default is for API and
|
||||
// agent callers. Returns the plops it created.
|
||||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||||
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
||||
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
||||
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
||||
// radius pitch, kept where the center is inside the region. A candidate is
|
||||
// skipped when its plop would sit entirely inside an existing active plop (so
|
||||
// re-filling doesn't stack duplicates). Returns the plops it created.
|
||||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
|
||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
|
||||
}
|
||||
|
||||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||||
// already loaded and authorized (roleEditor). It validates the layout, rejects a
|
||||
// non-finite region, clamps the region to the object's bounds, refuses fills over
|
||||
// maxFillPlops, and inserts the whole batch in one transaction rather than one
|
||||
// round-trip per plop.
|
||||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||||
// already loaded and authorized (roleEditor). It clamps the region to the
|
||||
// object's bounds, refuses fills over maxFillPlops, and inserts the whole batch
|
||||
// in one transaction rather than one round-trip per plop.
|
||||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||||
if !o.Plantable {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
if !validDatePtr(plantedAt) {
|
||||
return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||||
}
|
||||
layout, ok := validFillLayout(layout)
|
||||
if !ok {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
plant, err := s.visiblePlant(ctx, actorID, plantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -213,26 +124,14 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
}
|
||||
spacing = *spacingOverride
|
||||
}
|
||||
radius := plopRadiusFor(spacing, layout)
|
||||
radius := defaultPlopRadius(spacing)
|
||||
if !isFinite(radius) || radius <= 0 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
// A caller-supplied region is arbitrary floats, and non-finite ones survive
|
||||
// everything downstream: clamping keeps them, the inverted-region guard can't
|
||||
// see NaN (it compares false both ways), and fitAxis centres on them happily.
|
||||
// Nothing corrupt reaches the table — SQLite stores NaN as NULL and the NOT
|
||||
// NULL constraint refuses it — but the caller gets an opaque store error for
|
||||
// NaN, and for +Inf a silent zero-plop success. Both are lies about what went
|
||||
// wrong; say "bad input" here instead.
|
||||
if !isFinite(region.MinX) || !isFinite(region.MinY) ||
|
||||
!isFinite(region.MaxX) || !isFinite(region.MaxY) {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
||||
centers, total := hexCenters(region, radius, edgeInset(radius, spacing, layout), maxFillPlops)
|
||||
if total > maxFillPlops {
|
||||
centers := hexCenters(region, radius)
|
||||
if len(centers) > maxFillPlops {
|
||||
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
||||
}
|
||||
|
||||
@@ -240,22 +139,15 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plantedOn := s.now().UTC().Format(dateLayout)
|
||||
if plantedAt != nil {
|
||||
plantedOn = *plantedAt
|
||||
}
|
||||
today := s.now().UTC().Format(dateLayout)
|
||||
batch := make([]*domain.Planting, 0, len(centers))
|
||||
// Only the plops that were ALREADY here can cover a candidate: every plop this
|
||||
// fill makes shares one radius and sits on a distinct lattice point, and a plop
|
||||
// is "covered" only when it lies entirely inside another — impossible between
|
||||
// two equal-radius circles at different centres. So skip against `existing` as
|
||||
// loaded and don't grow it per plop, which made an empty-bed grid fill's check
|
||||
// needlessly quadratic.
|
||||
for _, c := range centers {
|
||||
if coveredByExisting(c.x, c.y, radius, existing) {
|
||||
continue
|
||||
}
|
||||
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn})
|
||||
p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
|
||||
batch = append(batch, p)
|
||||
existing = append(existing, *p) // so later candidates in THIS fill don't stack on it
|
||||
}
|
||||
created, err := s.store.CreatePlantings(ctx, batch)
|
||||
if err != nil {
|
||||
@@ -277,97 +169,32 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
|
||||
type localPoint struct{ x, y float64 }
|
||||
|
||||
// hexCenters returns hex-packed lattice centers filling a region: rows radius·√3
|
||||
// apart, alternate rows offset by half a pitch, at a 2×radius pitch. The lattice
|
||||
// is CENTERED, so the leftover is shared between opposite edges instead of piling
|
||||
// up against the far one.
|
||||
//
|
||||
// # How close to the edge the outer row goes
|
||||
//
|
||||
// The caller passes `inset`: the margin the outer row keeps from every edge. It
|
||||
// encodes the half-spacing rule (spacing is owed between neighbouring plants, and
|
||||
// a bed edge is nobody's neighbour) and differs by layout — see edgeInset, which
|
||||
// derives it. hexCenters just honours it on all four sides.
|
||||
//
|
||||
// Do not "simplify" the centering back to anchoring at the region's min corner.
|
||||
// That is what #75 was: staggered rows start a full pitch in, and the leftover all
|
||||
// lands on the far edge, where clumps hang outside a bed that nothing clips them to.
|
||||
//
|
||||
// # Counting before building
|
||||
//
|
||||
// hexCenters returns the total alongside the points, and works that total out
|
||||
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
|
||||
// its whole lattice first just to be counted and thrown away. Over `limit` it
|
||||
// returns (nil, total), so the caller can still refuse with the real number.
|
||||
func hexCenters(r Region, radius, inset float64, limit int) ([]localPoint, int) {
|
||||
// hexCenters returns hex-packed lattice centers whose center lies in the region.
|
||||
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
|
||||
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
|
||||
// radius inside the region's min corner so the first plop sits inside it.
|
||||
func hexCenters(r Region, radius float64) []localPoint {
|
||||
if radius <= 0 {
|
||||
return nil, 0
|
||||
}
|
||||
// An empty region has no inside to plant. The old loop-until-past-MaxX form
|
||||
// got this for free by never entering the loop; counting positions up front
|
||||
// does not, and would site a plop off the bed.
|
||||
if r.empty() {
|
||||
return nil, 0
|
||||
return nil
|
||||
}
|
||||
pitch := 2 * radius
|
||||
rowH := pitch * math.Sqrt(3) / 2
|
||||
|
||||
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
|
||||
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
|
||||
|
||||
// Exact, not an upper bound: staggered rows hold one fewer, so rows*cols would
|
||||
// over-reserve by ~12% — and, more to the point, allocating it is the thing we
|
||||
// are trying to avoid when the answer is "too many".
|
||||
staggered := cols
|
||||
if cols > 1 {
|
||||
staggered = cols - 1
|
||||
}
|
||||
total := (rows+1)/2*cols + rows/2*staggered
|
||||
if total > limit {
|
||||
return nil, total
|
||||
}
|
||||
|
||||
pts := make([]localPoint, 0, total)
|
||||
for row := 0; row < rows; row++ {
|
||||
y := r.MinY + y0 + float64(row)*rowH
|
||||
n, x := cols, r.MinX+x0
|
||||
// The stagger falls out of centering: an offset row holds one fewer plop,
|
||||
// and centering THAT run puts it exactly half a pitch off its neighbours.
|
||||
// A single-column region has nothing to stagger against.
|
||||
if row%2 == 1 && cols > 1 {
|
||||
n, x = staggered, r.MinX+x0+pitch/2
|
||||
const eps = 1e-6
|
||||
var pts []localPoint
|
||||
row := 0
|
||||
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
|
||||
xStart := r.MinX + radius
|
||||
if row%2 == 1 {
|
||||
xStart += radius
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
pts = append(pts, localPoint{x + float64(i)*pitch, y})
|
||||
for x := xStart; x <= r.MaxX+eps; x += pitch {
|
||||
if r.contains(x, y) {
|
||||
pts = append(pts, localPoint{x, y})
|
||||
}
|
||||
}
|
||||
row++
|
||||
}
|
||||
return pts, total
|
||||
}
|
||||
|
||||
// fitAxis returns how many lattice positions fit along a span at `step`, keeping
|
||||
// at least `inset` from each end, and the offset from the span's start that
|
||||
// centers them — so the leftover is split between the two edges rather than all
|
||||
// landing on the far one.
|
||||
//
|
||||
// A span too small to hold even one position at that inset still gets one, in the
|
||||
// middle: filling a bed narrower than a single plop with one plop is a better
|
||||
// answer than refusing to plant it.
|
||||
//
|
||||
// The step<=0 half of that guard is currently unreachable — hexCenters, the only
|
||||
// caller, returns early unless radius > 0, which makes both steps it passes
|
||||
// positive. It stays because dividing by a non-positive step yields ±Inf and then
|
||||
// a garbage int conversion, and a helper this small should not require reading
|
||||
// its caller to know it is safe. Deliberate, not an oversight.
|
||||
func fitAxis(length, step, inset float64) (n int, start float64) {
|
||||
if step <= 0 || length < 2*inset {
|
||||
return 1, length / 2
|
||||
}
|
||||
// The epsilon keeps an exact fit from being lost to floating point — a 60cm
|
||||
// span at a 30cm step should give 2 positions, not 1 because the division
|
||||
// landed on 0.9999999.
|
||||
const eps = 1e-9
|
||||
n = int(math.Floor((length-2*inset)/step+eps)) + 1
|
||||
return n, (length - float64(n-1)*step) / 2
|
||||
return pts
|
||||
}
|
||||
|
||||
// coveredByExisting reports whether a new plop (center, radius) would sit
|
||||
@@ -384,7 +211,7 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
|
||||
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
|
||||
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
||||
// hold the object's geometry. It resolves the name against the object, then fills.
|
||||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -393,7 +220,7 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
|
||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
|
||||
}
|
||||
|
||||
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
||||
@@ -479,11 +306,7 @@ type DescribeObject struct {
|
||||
}
|
||||
|
||||
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
|
||||
// ID + Version are included so an agent can address a single plop — remove it or
|
||||
// move it — the same way DescribeObject.Version lets it edit an object.
|
||||
type DescribePlanting struct {
|
||||
ID int64 `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
PlantID int64 `json:"plantId"`
|
||||
Plant string `json:"plant"`
|
||||
Count int `json:"count"`
|
||||
@@ -530,8 +353,6 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
|
||||
count = *pl.Count
|
||||
}
|
||||
do.Plantings = append(do.Plantings, DescribePlanting{
|
||||
ID: pl.ID,
|
||||
Version: pl.Version,
|
||||
PlantID: pl.PlantID,
|
||||
Plant: plantByID[pl.PlantID].Name,
|
||||
Count: count,
|
||||
|
||||
@@ -3,8 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -59,7 +57,7 @@ func TestFillRegionCappedForHugeArea(t *testing.T) {
|
||||
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
|
||||
plant := seedOwnPlant(t, s, owner, 10)
|
||||
region, _ := NamedRegion(bed, "all")
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
|
||||
}
|
||||
}
|
||||
@@ -73,165 +71,6 @@ func TestDefaultPlopRadius(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexCentersEdgeInset pins the spacing rule the packing exists to honour:
|
||||
// spacing is a constraint between neighbouring plants, so a bed edge — which is
|
||||
// nobody's neighbour — is owed half a pitch, not a whole one.
|
||||
//
|
||||
// The bug this guards against was visible to anyone who filled a bed: staggered
|
||||
// rows began a full pitch in, leaving a bare strip a whole plop wide down one
|
||||
// side of every other row, while the far edge had plops hanging off it.
|
||||
func TestHexCentersEdgeInset(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
w, h, radius, spacing float64
|
||||
wantRowStarts []float64 // x of the first plop in rows 0 and 1
|
||||
}{
|
||||
// 4ft × 8ft bed, garlic at 15cm spacing → radius 22.5, pitch 45. Three
|
||||
// columns, the outer ones overhanging by 6.5cm — under the 7.5cm the rule
|
||||
// allows. Anchored at the corner this row started at -38.5 and its
|
||||
// staggered neighbour a full 45 further in still.
|
||||
{"4ft bed of garlic", 122, 244, 22.5, 15, []float64{-45, -22.5}},
|
||||
// An exact fit: 90 wide at pitch 30 → 3 columns, no overhang needed.
|
||||
{"exact fit", 90, 90, 15, 10, []float64{-30, -15}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
|
||||
pts, total := hexCenters(r, tc.radius, edgeInset(tc.radius, tc.spacing, FillClump), maxFillPlops)
|
||||
if len(pts) == 0 {
|
||||
t.Fatal("no centers")
|
||||
}
|
||||
// The count is derived up front so an oversized fill is refused without
|
||||
// building its lattice — which only works if it matches what gets built.
|
||||
if total != len(pts) {
|
||||
t.Errorf("reported total %d, built %d", total, len(pts))
|
||||
}
|
||||
|
||||
// A clump may cross the edge, but only by the half-spacing the rule
|
||||
// allows — never enough to be mostly out in the path.
|
||||
budget := tc.spacing / 2
|
||||
for _, p := range pts {
|
||||
over := math.Max(
|
||||
math.Max(r.MinX-(p.x-tc.radius), (p.x+tc.radius)-r.MaxX),
|
||||
math.Max(r.MinY-(p.y-tc.radius), (p.y+tc.radius)-r.MaxY),
|
||||
)
|
||||
if over > budget+1e-6 {
|
||||
t.Errorf("plop at (%.1f,%.1f) overhangs by %.2f, budget %.2f", p.x, p.y, over, budget)
|
||||
}
|
||||
}
|
||||
|
||||
// The margins match on opposite edges: the leftover is shared, not piled
|
||||
// against the far side.
|
||||
minX, maxX, minY, maxY := pts[0].x, pts[0].x, pts[0].y, pts[0].y
|
||||
for _, p := range pts {
|
||||
minX, maxX = math.Min(minX, p.x), math.Max(maxX, p.x)
|
||||
minY, maxY = math.Min(minY, p.y), math.Max(maxY, p.y)
|
||||
}
|
||||
if w, e := minX-r.MinX, r.MaxX-maxX; math.Abs(w-e) > 1e-6 {
|
||||
t.Errorf("lopsided horizontally: west margin %.2f, east %.2f", w, e)
|
||||
}
|
||||
if n, s := minY-r.MinY, r.MaxY-maxY; math.Abs(n-s) > 1e-6 {
|
||||
t.Errorf("lopsided vertically: north margin %.2f, south %.2f", n, s)
|
||||
}
|
||||
|
||||
// The staggered row is offset by HALF a pitch, not a whole one.
|
||||
starts := map[float64]float64{}
|
||||
for _, p := range pts {
|
||||
if x, ok := starts[p.y]; !ok || p.x < x {
|
||||
starts[p.y] = p.x
|
||||
}
|
||||
}
|
||||
ys := make([]float64, 0, len(starts))
|
||||
for y := range starts {
|
||||
ys = append(ys, y)
|
||||
}
|
||||
sort.Float64s(ys)
|
||||
for i, want := range tc.wantRowStarts {
|
||||
if i >= len(ys) {
|
||||
t.Fatalf("only %d rows, want at least %d", len(ys), len(tc.wantRowStarts))
|
||||
}
|
||||
if got := starts[ys[i]]; math.Abs(got-want) > 1e-6 {
|
||||
t.Errorf("row %d starts at x=%.2f, want %.2f", i, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexCentersTinyRegion covers a region too small to hold a plop at the
|
||||
// half-pitch inset: planting one in the middle beats refusing to plant at all.
|
||||
//
|
||||
// The off-centre case earns its place — a region symmetric about the origin
|
||||
// can't tell "the middle of the region" from "the origin", so on its own it
|
||||
// would pass for an implementation that just returned (0,0).
|
||||
func TestHexCentersTinyRegion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
r Region
|
||||
wantX, wantY float64
|
||||
}{
|
||||
{"centred on the origin", rect(-5, -5, 5, 5), 0, 0},
|
||||
{"off in a corner", rect(20, -40, 30, -30), 25, -35},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pts, _ := hexCenters(tc.r, 15, edgeInset(15, 10, FillClump), maxFillPlops)
|
||||
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
|
||||
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRegionRejectsNonFiniteRegion: non-finite bounds survive clamping and
|
||||
// the inverted-region guard (NaN compares false both ways). Without the explicit
|
||||
// check, NaN surfaced as a raw store error ("NOT NULL constraint failed") and
|
||||
// +Inf as a silent success that planted nothing — neither of which tells the
|
||||
// caller what it actually did wrong.
|
||||
func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||
bed := seedFillBed(t, s, owner, g.ID, 100, 100)
|
||||
plant := seedOwnPlant(t, s, owner, 10)
|
||||
|
||||
nan := math.NaN()
|
||||
for _, r := range []Region{
|
||||
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
|
||||
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
|
||||
} {
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump, nil)
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
|
||||
}
|
||||
for _, p := range created {
|
||||
if !isFinite(p.XCM) || !isFinite(p.YCM) {
|
||||
t.Errorf("persisted a plop with non-finite coordinates: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRegionOutsideObjectPlantsNothing covers a region that misses the object
|
||||
// entirely. clampTo inverts such a region rather than emptying it, and an
|
||||
// inverted region must plant nothing — not one plop at some point off the bed.
|
||||
func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||
bed := seedFillBed(t, s, owner, g.ID, 100, 100) // local bounds ±50
|
||||
plant := seedOwnPlant(t, s, owner, 10)
|
||||
|
||||
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FillRegion: %v", err)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
t.Errorf("filled %d plops for a region outside the bed, want 0: %+v", len(created), created)
|
||||
}
|
||||
}
|
||||
|
||||
// seedFillBed makes a plantable bed of the given size centered in a big garden.
|
||||
func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject {
|
||||
t.Helper()
|
||||
@@ -256,34 +95,26 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
||||
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
|
||||
|
||||
region, _ := NamedRegion(bed, "all")
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FillRegion: %v", err)
|
||||
}
|
||||
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart, centered: a row of 2
|
||||
// (x=±15), then a staggered row of 1 (x=0) → 3 plops.
|
||||
//
|
||||
// This was 4 while the lattice was anchored at the min corner, and the fourth
|
||||
// sat at x=30 — centred ON the east edge, so half of it lay outside the bed,
|
||||
// well past the half-spacing (5cm here) the rule allows. Packing one fewer
|
||||
// plop is the point of the fix, not a regression in it.
|
||||
if len(created) != 3 {
|
||||
t.Fatalf("filled %d plops, want 3 (60×60 bed, radius 15)", len(created))
|
||||
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart → 4 plops (2 rows × 2).
|
||||
if len(created) != 4 {
|
||||
t.Fatalf("filled %d plops, want 4 (60×60 bed, radius 15)", len(created))
|
||||
}
|
||||
for _, p := range created {
|
||||
if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 {
|
||||
t.Errorf("unexpected created plop: %+v", p)
|
||||
}
|
||||
// This bed fits its lattice exactly, so nothing should need to overhang.
|
||||
if p.XCM-p.RadiusCM < -30 || p.XCM+p.RadiusCM > 30 ||
|
||||
p.YCM-p.RadiusCM < -30 || p.YCM+p.RadiusCM > 30 {
|
||||
t.Errorf("plop overhangs a bed it fits inside: %+v", p)
|
||||
if p.XCM < -30 || p.XCM > 30 || p.YCM < -30 || p.YCM > 30 {
|
||||
t.Errorf("plop center out of bed bounds: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-filling the same region skips everything (each candidate sits exactly on
|
||||
// an existing plop → entirely inside it).
|
||||
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
|
||||
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("second FillRegion: %v", err)
|
||||
}
|
||||
@@ -292,70 +123,6 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillGridLaysOutIndividualPlants is the #77 grid mode: a grid fill packs one
|
||||
// plant per plop at true spacing, so a bed becomes rows of plants rather than a
|
||||
// few fat clumps. On the same bed it produces many more, smaller plops, each a
|
||||
// single plant.
|
||||
func TestFillGridLaysOutIndividualPlants(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||
bed := seedFillBed(t, s, owner, g.ID, 60, 60)
|
||||
plant := seedOwnPlant(t, s, owner, 10) // spacing 10
|
||||
|
||||
region, _ := NamedRegion(bed, "all")
|
||||
clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("clump: %v", err)
|
||||
}
|
||||
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("grid: %v", err)
|
||||
}
|
||||
|
||||
// Grid packs at spacing 10 (radius 5, pitch 10); clump at radius 15 (pitch 30).
|
||||
// Grid must produce many more plops.
|
||||
if len(grid) <= len(clump) {
|
||||
t.Errorf("grid produced %d plops, clump %d — grid should be denser", len(grid), len(clump))
|
||||
}
|
||||
// Each grid plop is one plant at radius spacing/2 = 5.
|
||||
maxAbs := 0.0
|
||||
for _, p := range grid {
|
||||
if p.RadiusCM != 5 {
|
||||
t.Errorf("grid plop radius = %v, want 5 (spacing/2)", p.RadiusCM)
|
||||
}
|
||||
if p.DerivedCount != 1 {
|
||||
t.Errorf("grid plop derived count = %d, want 1 (one plant per plop)", p.DerivedCount)
|
||||
}
|
||||
maxAbs = math.Max(maxAbs, math.Max(math.Abs(p.XCM), math.Abs(p.YCM)))
|
||||
}
|
||||
// The half-spacing edge rule: a grid plant sits AT the plop centre, so the
|
||||
// outer row is inset a half-spacing (spacing/2 = 5) — at ±25 on this 60cm bed,
|
||||
// not flush on ±30. Regression guard: the clump inset formula (radius − half)
|
||||
// collapses to 0 for grid and would plant one on the very edge.
|
||||
if edge := 30.0; maxAbs > edge-5+1e-6 {
|
||||
t.Errorf("outermost grid plop at |coord|=%.2f — only %.2fcm from the edge; want a half-spacing (5cm) in", maxAbs, edge-maxAbs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRejectsUnknownLayout: a layout that isn't clump/grid is ErrInvalidInput.
|
||||
func TestFillRejectsUnknownLayout(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||
bed := seedFillBed(t, s, owner, g.ID, 60, 60)
|
||||
plant := seedOwnPlant(t, s, owner, 10)
|
||||
region, _ := NamedRegion(bed, "all")
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral"), nil); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
@@ -368,7 +135,7 @@ func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
|
||||
plant := seedOwnPlant(t, s, owner, 20)
|
||||
|
||||
region, _ := NamedRegion(bed, "ne")
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FillRegion: %v", err)
|
||||
}
|
||||
@@ -391,7 +158,7 @@ func TestClearObject(t *testing.T) {
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 10)
|
||||
region, _ := NamedRegion(bed, "all")
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); err != nil {
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); err != nil {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
|
||||
@@ -425,7 +192,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
|
||||
}
|
||||
region, _ := NamedRegion(bed, "all")
|
||||
|
||||
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrForbidden) {
|
||||
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Errorf("viewer fill = %v, want ErrForbidden", err)
|
||||
}
|
||||
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
|
||||
@@ -455,7 +222,7 @@ func TestFillScenario(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("region %q: %v", name, err)
|
||||
}
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump, nil); err != nil {
|
||||
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil); err != nil {
|
||||
t.Fatalf("fill %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -507,34 +274,3 @@ func seedNamedPlant(t *testing.T, s *Service, owner int64, name string, spacingC
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestFillRegionPlantedAt: a fill dates its plops as told and refuses a date
|
||||
// that isn't one. The UI sends its local day, so an evening fill isn't stamped
|
||||
// with UTC's tomorrow; API and agent callers that omit it still get UTC today.
|
||||
func TestFillRegionPlantedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Dated", WidthCM: 2000, HeightCM: 2000})
|
||||
bed := seedFillBed(t, s, owner, g.ID, 200, 100)
|
||||
plant := seedOwnPlant(t, s, owner, 30)
|
||||
|
||||
day := "2026-04-01"
|
||||
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &day)
|
||||
if err != nil {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatal("fill created nothing")
|
||||
}
|
||||
for _, p := range created {
|
||||
if p.PlantedAt == nil || *p.PlantedAt != day {
|
||||
t.Errorf("planting %d plantedAt = %v, want %s", p.ID, p.PlantedAt, day)
|
||||
}
|
||||
}
|
||||
|
||||
bad := "April 1st"
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &bad); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,17 +178,6 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// RemovePlanting soft-removes a single plop — the one-plop counterpart to
|
||||
// ClearObject, used by the agent's remove_planting tool. It stamps removed_at
|
||||
// from the service clock (s.now()), same as ClearObject and the fill path, so the
|
||||
// removal date can't diverge by which caller set it; then delegates to
|
||||
// UpdatePlanting for the editor-role check, version guard and history record.
|
||||
func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) {
|
||||
today := s.now().UTC().Format(dateLayout)
|
||||
return s.UpdatePlanting(ctx, actorID, plantingID,
|
||||
PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version)
|
||||
}
|
||||
|
||||
// plantingEditSummary describes a plop edit for the history list. Soft-removal
|
||||
// ("clear bed", harvested) is the one edit worth naming specifically — it reads
|
||||
// as a removal to the person who did it, not as an edit.
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -130,7 +129,11 @@ func (s *Service) WithChangeSet(ctx context.Context, actorID, gardenID int64, op
|
||||
// which is exactly the situation undo exists for. Record what happened,
|
||||
// mark the summary, and still report the failure.
|
||||
sc.summary = partialSummary(sc.summary)
|
||||
if _, cerr := s.commitScope(ctx, sc, nil); cerr != nil {
|
||||
// Detached from cancellation on purpose. The commonest reason fn failed is
|
||||
// that ctx was cancelled or timed out — and using that same dead context
|
||||
// to record what committed would fail too, losing the history for changes
|
||||
// that really happened. That is precisely the case this path exists for.
|
||||
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, nil); cerr != nil {
|
||||
slog.Error("service: partial turn could not be recorded", "error", cerr, "garden", gardenID)
|
||||
}
|
||||
return nil, err
|
||||
@@ -150,19 +153,11 @@ func partialSummary(summary string) string {
|
||||
// commitScope writes a scope's buffered revisions as one change set. revertsID is
|
||||
// set only by RevertChangeSet. A scope with no revisions writes nothing — an
|
||||
// operation that changed nothing doesn't belong in history.
|
||||
//
|
||||
// The write is DETACHED FROM CANCELLATION, always, and that belongs here rather
|
||||
// than at each call site so no caller can be the one that forgets. By the time a
|
||||
// commit runs, the data changes it describes have already been written — so
|
||||
// cancelling it cannot undo anything. It can only lose the record of what
|
||||
// happened and leave real changes with no way to undo them, which is the one
|
||||
// thing the whole change-set design exists to prevent.
|
||||
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
|
||||
revs := sc.taken()
|
||||
if len(revs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
return s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: sc.gardenID,
|
||||
ActorID: sc.actorID,
|
||||
@@ -203,13 +198,9 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
|
||||
sc.append(revs)
|
||||
return
|
||||
}
|
||||
// Auto-scope: one operation, its own change set. Written through the same
|
||||
// detached path as everything else — a REST client that hangs up right after
|
||||
// its PATCH landed must not leave that change without history, and this is
|
||||
// the path virtually every mutation takes.
|
||||
auto := &changeScope{gardenID: gardenID, actorID: actorID, source: domain.SourceUI, summary: summary}
|
||||
auto.append(revs)
|
||||
if _, err := s.commitScope(ctx, auto, nil); err != nil {
|
||||
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary,
|
||||
}, revs); err != nil {
|
||||
slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary)
|
||||
}
|
||||
}
|
||||
@@ -320,9 +311,10 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
|
||||
changes, conflict, err := s.applyInverse(ctx, r, applied)
|
||||
if err != nil {
|
||||
// Record what actually landed before surfacing the failure, so the
|
||||
// partial revert is visible and undoable rather than orphaned.
|
||||
// (commitScope detaches from cancellation itself.)
|
||||
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
|
||||
// partial revert is visible and undoable rather than orphaned. Detached
|
||||
// from cancellation for the same reason as WithChangeSet's path: a
|
||||
// timed-out context can't be used to write the record of what it did.
|
||||
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, &target.ID); cerr != nil {
|
||||
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
|
||||
}
|
||||
return nil, nil, err
|
||||
@@ -342,48 +334,9 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Fill in the per-op counts. commitScope returns the freshly inserted row,
|
||||
// which carries no tally — and a caller receiving a change set with an empty
|
||||
// Counts cannot tell "nothing was reverted" from "the tally wasn't loaded".
|
||||
// That ambiguity is not theoretical: it made the chat panel report a
|
||||
// successful undo as "nothing left to undo".
|
||||
if cs != nil {
|
||||
cs.Counts = countRevisions(sc.taken())
|
||||
}
|
||||
return cs, conflicts, nil
|
||||
}
|
||||
|
||||
// countRevisions tallies revisions by (entity type, op).
|
||||
//
|
||||
// This deliberately reproduces in Go what ListChangeSets does in SQL, because a
|
||||
// change set that has just been written has no rows to GROUP BY yet — the
|
||||
// alternative is a second round trip to count what we are already holding.
|
||||
// The parity is a real cross-layer contract, and TestRevertResultCarriesItsCounts
|
||||
// compares the two breakdowns row for row so it can't drift silently.
|
||||
func countRevisions(revs []domain.Revision) []domain.ChangeCount {
|
||||
type key struct{ entityType, op string }
|
||||
seen := map[key]int{}
|
||||
order := []key{}
|
||||
for _, r := range revs {
|
||||
k := key{r.EntityType, r.Op}
|
||||
if _, ok := seen[k]; !ok {
|
||||
order = append(order, k)
|
||||
}
|
||||
seen[k]++
|
||||
}
|
||||
sort.Slice(order, func(i, j int) bool {
|
||||
if order[i].entityType != order[j].entityType {
|
||||
return order[i].entityType < order[j].entityType
|
||||
}
|
||||
return order[i].op < order[j].op
|
||||
})
|
||||
counts := make([]domain.ChangeCount, 0, len(order))
|
||||
for _, k := range order {
|
||||
counts = append(counts, domain.ChangeCount{EntityType: k.entityType, Op: k.op, N: seen[k]})
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// entityKey identifies a row across the entity types a revision can name.
|
||||
type entityKey struct {
|
||||
entityType string
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) {
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil)
|
||||
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FillNamedRegion: %v", err)
|
||||
}
|
||||
@@ -320,7 +320,7 @@ func TestRevertClearObject(t *testing.T) {
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||
@@ -688,7 +688,7 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
||||
@@ -704,192 +704,3 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
|
||||
t.Errorf("cleared %d of %d with %d revisions — all three should match", n, len(before), len(revs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertResultCarriesItsCounts — found by using the thing.
|
||||
//
|
||||
// commitScope returns the freshly inserted row, which carries no tally. A caller
|
||||
// receiving a change set with empty Counts cannot tell "nothing was reverted"
|
||||
// from "the tally wasn't loaded", and the chat panel read that ambiguity the
|
||||
// wrong way: a successful undo reported "nothing left to undo", which is the
|
||||
// worst possible thing to say about an action that just worked.
|
||||
//
|
||||
// It also pins the cross-layer contract that fix created. countRevisions tallies
|
||||
// in Go what ListChangeSets tallies in SQL, and nothing but this test stops the
|
||||
// two drifting — so it compares the FULL per-(entity, op) breakdown, not just
|
||||
// the totals, which would agree even if the groupings had diverged.
|
||||
func TestRevertResultCarriesItsCounts(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
// A second, different kind of change, so the breakdown has more than one row
|
||||
// to get wrong.
|
||||
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
sets := history(t, s, owner, g.ID)
|
||||
rename, fill := sets[0], sets[1]
|
||||
|
||||
undoRename, conflicts, err := s.RevertChangeSet(ctx, owner, rename.ID, domain.SourceUI)
|
||||
if err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("revert rename: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
undoFill, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
|
||||
if err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("revert fill: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
|
||||
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
|
||||
if undo == nil {
|
||||
t.Fatal("a revert that did work returned no change set")
|
||||
}
|
||||
if len(undo.Counts) == 0 {
|
||||
t.Fatalf("change set %d came back with no tally — an empty tally reads as 'nothing happened'", undo.ID)
|
||||
}
|
||||
}
|
||||
// Undoing a fill deletes every plop it created, so the tallies must match.
|
||||
if got, want := countsTotal(undoFill.Counts), countsTotal(fill.Counts); got != want {
|
||||
t.Errorf("undo of the fill reports %d changes, want %d", got, want)
|
||||
}
|
||||
|
||||
// The SQL tally and the Go tally must agree, row for row.
|
||||
listed := history(t, s, owner, g.ID)
|
||||
byID := map[int64][]domain.ChangeCount{}
|
||||
for _, cs := range listed {
|
||||
byID[cs.ID] = cs.Counts
|
||||
}
|
||||
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
|
||||
fromSQL, ok := byID[undo.ID]
|
||||
if !ok {
|
||||
t.Fatalf("revert %d is missing from the history list", undo.ID)
|
||||
}
|
||||
if !sameCounts(undo.Counts, fromSQL) {
|
||||
t.Errorf("change set %d: revert returned %+v, the list read says %+v — countRevisions and the SQL grouping have drifted",
|
||||
undo.ID, undo.Counts, fromSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sameCounts compares two tallies regardless of order.
|
||||
func sameCounts(a, b []domain.ChangeCount) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
index := func(cs []domain.ChangeCount) map[string]int {
|
||||
m := map[string]int{}
|
||||
for _, c := range cs {
|
||||
m[c.EntityType+"/"+c.Op] = c.N
|
||||
}
|
||||
return m
|
||||
}
|
||||
ia, ib := index(a), index(b)
|
||||
for k, v := range ia {
|
||||
if ib[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// countsTotal sums a tally — the server-side twin of the client's totalChanges.
|
||||
func countsTotal(counts []domain.ChangeCount) int {
|
||||
n := 0
|
||||
for _, c := range counts {
|
||||
n += c.N
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestSucceededTurnRecordsEvenIfTheCallerWentAway is the production bug.
|
||||
//
|
||||
// The failure path was detached from cancellation; the SUCCESS path was not. An
|
||||
// agent turn whose client disconnects can still COMPLETE — and then the success
|
||||
// path committed with a dead context, the write failed, and real changes were
|
||||
// left with no change set and no way to undo them. Found live with 18 plantings
|
||||
// behind no history at all.
|
||||
//
|
||||
// Nothing about a commit needs the caller to still be there: by the time it
|
||||
// runs, the data it describes has already been written.
|
||||
func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
before := len(history(t, s, owner, g.ID))
|
||||
|
||||
// fn does its work and SUCCEEDS, but the caller goes away before it returns.
|
||||
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
|
||||
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
|
||||
}, func(ctx context.Context) error {
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
cancel() // the client disconnects, mid-turn, after the work landed
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithChangeSet: %v", err)
|
||||
}
|
||||
if cs == nil {
|
||||
t.Fatal("the turn changed things but produced no change set")
|
||||
}
|
||||
|
||||
after := history(t, s, owner, g.ID)
|
||||
if len(after) != before+1 {
|
||||
t.Fatalf("recorded %d change sets, want 1", len(after)-before)
|
||||
}
|
||||
if after[0].Summary != "plant beans in the second bed" {
|
||||
t.Errorf("summary = %q; a completed turn shouldn't be marked partial", after[0].Summary)
|
||||
}
|
||||
// And it's undoable, which is the entire point.
|
||||
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the recorded turn should be undoable: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
active, _ := s.store.ListActivePlantingsForObject(context.Background(), bed.ID)
|
||||
if len(active) != 0 {
|
||||
t.Errorf("%d plantings survived the undo", len(active))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoScopedMutationRecordsEvenIfTheCallerWentAway — the same rule for the
|
||||
// path virtually every mutation takes.
|
||||
//
|
||||
// A plain REST PATCH auto-scopes into its own change set. If the client hangs up
|
||||
// between the row landing and the change set being written, that change is
|
||||
// orphaned exactly as an agent turn's was — and this path is used far more.
|
||||
func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
before := len(history(t, s, owner, g.ID))
|
||||
|
||||
// The row write and the history write share this context; cancelling after
|
||||
// the mutation returns is the client hanging up mid-request.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
|
||||
t.Fatalf("UpdateObject: %v", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
after := history(t, s, owner, g.ID)
|
||||
if len(after) != before+1 {
|
||||
t.Fatalf("recorded %d change sets, want 1 — the move is otherwise un-undoable", len(after)-before)
|
||||
}
|
||||
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, after[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the recorded move should be undoable: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
back, _ := s.store.GetObject(context.Background(), bed.ID)
|
||||
if back.XCM != bed.XCM {
|
||||
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,12 +99,9 @@ func TestRemainingReturnsWhenAPlantingIsRemoved(t *testing.T) {
|
||||
lot := seedLot(t, s, owner, plant.ID, 100, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// Dated explicitly: left to default, plantedAt is the real UTC day, and the
|
||||
// removal below has to come after it — a test that only passed before
|
||||
// 2026-08-01 is the kind of clock bomb this avoids.
|
||||
ten, planted := 10, "2026-07-01"
|
||||
ten := 10
|
||||
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, PlantedAt: &planted,
|
||||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePlanting: %v", err)
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
// Seed-packet capture (#81): read a photographed packet, propose a plant + lot,
|
||||
// let the user confirm. The two halves are deliberately separate operations:
|
||||
// extraction only READS (a picture in, a proposal out — it can't touch the
|
||||
// garden), and creation happens later, from the confirmed proposal, so a
|
||||
// misread never writes anything on its own.
|
||||
|
||||
// PacketPlantMatch is a candidate existing plant the packet might be, with why it
|
||||
// matched, so the UI can pre-select the likely one and let the user override.
|
||||
type PacketPlantMatch struct {
|
||||
Plant domain.Plant `json:"plant"`
|
||||
// Reason is a short human tag: "exact name", "variety in name", "same species".
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// PacketProposal is what a scan returns: the fields read off the packet, plus
|
||||
// candidate existing plants it might already be. Nothing is created yet.
|
||||
type PacketProposal struct {
|
||||
Packet vision.SeedPacket `json:"packet"`
|
||||
// Candidates are existing plants the packet may match, best first. Empty means
|
||||
// "probably a new variety" — the UI then offers to create one.
|
||||
Candidates []PacketPlantMatch `json:"candidates"`
|
||||
// SuggestedName is the variety (or species) to prefill a new-plant name with.
|
||||
SuggestedName string `json:"suggestedName"`
|
||||
// SuggestedCategory is the packet's category if it's a valid one, for prefill.
|
||||
SuggestedCategory string `json:"suggestedCategory"`
|
||||
}
|
||||
|
||||
// ExtractSeedPacket reads a (JPEG) packet photo and proposes a plant + lot for
|
||||
// the actor to confirm. It needs a configured vision model; with none it returns
|
||||
// ErrInvalidInput (the API layer turns "not configured" into a clear message and
|
||||
// never offers the feature in the first place).
|
||||
//
|
||||
// The extraction runs as the actor only in the sense that the catalog match is
|
||||
// scoped to what they can see; the model call itself has no ACL — it just reads
|
||||
// a picture the actor uploaded.
|
||||
func (s *Service) ExtractSeedPacket(ctx context.Context, actorID int64, jpeg []byte) (*PacketProposal, error) {
|
||||
if len(jpeg) == 0 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
vis, err := s.EffectiveVision(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !vis.Ready() {
|
||||
// No vision model configured — the feature isn't available.
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
packet, err := s.extractPacket(ctx, vis.APIKey, vis.Model, jpeg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plants, err := s.store.ListPlantsForActor(ctx, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PacketProposal{
|
||||
Packet: packet,
|
||||
Candidates: matchPlants(packet, plants),
|
||||
SuggestedName: suggestedName(packet),
|
||||
SuggestedCategory: validCategory(packet.Category),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// suggestedName is the variety if the packet named one, else the species — what
|
||||
// to prefill a new plant's name with. "Music" beats "garlic" when both are read.
|
||||
func suggestedName(p vision.SeedPacket) string {
|
||||
if v := strings.TrimSpace(p.Variety); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(p.Species)
|
||||
}
|
||||
|
||||
// validCategory returns the packet's category if it's one pansy knows, else "".
|
||||
// It reuses plantCategories — the same set CreatePlant validates against — so a
|
||||
// new category can't be accepted by one path and rejected by the other.
|
||||
func validCategory(c string) string {
|
||||
if _, ok := plantCategories[c]; ok {
|
||||
return c
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchPlants ranks existing plants the packet might already be, best first.
|
||||
//
|
||||
// This is the crux of "create both, linked" (#81): getting it wrong makes a
|
||||
// duplicate catalog entry that then splits a variety's seed-lot history across
|
||||
// two rows. So it NEVER decides — it only surfaces candidates for the user to
|
||||
// confirm. Matching is deliberately conservative and name-based (no fuzzy
|
||||
// scoring that could confidently mis-rank): exact variety name, variety appearing
|
||||
// within a plant's name, then same species word. Case-insensitive.
|
||||
func matchPlants(p vision.SeedPacket, plants []domain.Plant) []PacketPlantMatch {
|
||||
variety := strings.ToLower(strings.TrimSpace(p.Variety))
|
||||
species := strings.ToLower(strings.TrimSpace(p.Species))
|
||||
|
||||
// rank: lower is better; keep only matched plants.
|
||||
type scored struct {
|
||||
match PacketPlantMatch
|
||||
rank int
|
||||
}
|
||||
var out []scored
|
||||
seen := map[int64]bool{}
|
||||
add := func(pl domain.Plant, rank int, reason string) {
|
||||
if seen[pl.ID] {
|
||||
return
|
||||
}
|
||||
seen[pl.ID] = true
|
||||
out = append(out, scored{PacketPlantMatch{Plant: pl, Reason: reason}, rank})
|
||||
}
|
||||
|
||||
for _, pl := range plants {
|
||||
name := strings.ToLower(pl.Name)
|
||||
switch {
|
||||
case variety != "" && name == variety:
|
||||
add(pl, 0, "exact name")
|
||||
case variety != "" && strings.Contains(name, variety):
|
||||
add(pl, 1, "variety in name")
|
||||
case species != "" && wordIn(name, species):
|
||||
add(pl, 2, "same species")
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].rank < out[j].rank })
|
||||
|
||||
matches := make([]PacketPlantMatch, len(out))
|
||||
for i, s := range out {
|
||||
matches[i] = s.match
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// wordIn reports whether word appears as a whole space-delimited token in name,
|
||||
// so "garlic" matches "German Garlic" but not "garlicky-thing".
|
||||
func wordIn(name, word string) bool {
|
||||
for _, tok := range strings.Fields(name) {
|
||||
if tok == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PacketConfirm is a user-confirmed proposal to turn into rows.
|
||||
//
|
||||
// Plant selection is explicit: either PlantID names an existing plant to attach
|
||||
// the lot to, or NewPlant carries the fields to create one. Exactly one — the
|
||||
// service refuses both or neither, so an ambiguous confirm can't silently pick.
|
||||
type PacketConfirm struct {
|
||||
// PlantID attaches the lot to an existing plant. Set this XOR NewPlant.
|
||||
PlantID *int64
|
||||
// NewPlant creates a variety. Set this XOR PlantID.
|
||||
NewPlant *PlantInput
|
||||
// Lot is the purchase to record against whichever plant results.
|
||||
Lot SeedLotInput
|
||||
}
|
||||
|
||||
// PacketResult is what a confirm produced.
|
||||
type PacketResult struct {
|
||||
Plant *domain.Plant `json:"plant"`
|
||||
Lot *domain.SeedLot `json:"lot"`
|
||||
PlantIsNew bool `json:"plantIsNew"`
|
||||
}
|
||||
|
||||
// CreateFromPacket turns a confirmed proposal into a plant (new or existing) plus
|
||||
// a seed lot attributed to it. Unlike garden edits these rows aren't in the undo
|
||||
// history — plants and lots are catalog/inventory, created directly — so there is
|
||||
// no change set to wrap; the two creations just happen in sequence.
|
||||
//
|
||||
// If a new plant is created but the lot then fails, the plant is rolled back so
|
||||
// the confirm is all-or-nothing. Otherwise a bad lot (say, an invalid unit) would
|
||||
// strand a half-made catalog entry the user never asked for on its own, and the
|
||||
// error return gives the HTTP caller no handle to it. A just-created plant has no
|
||||
// plantings or lots yet, so the delete is safe; if it somehow can't be undone we
|
||||
// log and still surface the original lot error, not the cleanup one.
|
||||
func (s *Service) CreateFromPacket(ctx context.Context, actorID int64, in PacketConfirm) (*PacketResult, error) {
|
||||
hasID, hasNew := in.PlantID != nil, in.NewPlant != nil
|
||||
if hasID == hasNew {
|
||||
return nil, domain.ErrInvalidInput // exactly one of existing / new
|
||||
}
|
||||
|
||||
res := &PacketResult{}
|
||||
if hasNew {
|
||||
plant, err := s.CreatePlant(ctx, actorID, *in.NewPlant)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Plant = plant
|
||||
res.PlantIsNew = true
|
||||
} else {
|
||||
// Attach to an existing plant the actor can see. visiblePlant enforces the
|
||||
// ACL (built-ins + their own); a plant they can't see is ErrNotFound.
|
||||
plant, err := s.visiblePlant(ctx, actorID, *in.PlantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Plant = plant
|
||||
}
|
||||
|
||||
lotIn := in.Lot
|
||||
lotIn.PlantID = res.Plant.ID
|
||||
lot, err := s.CreateSeedLot(ctx, actorID, lotIn)
|
||||
if err != nil {
|
||||
if res.PlantIsNew {
|
||||
// Roll back the plant we just made for this confirm; keep the lot error.
|
||||
if delErr := s.DeletePlant(ctx, actorID, res.Plant.ID); delErr != nil {
|
||||
slog.Error("service: could not roll back plant after packet lot failed",
|
||||
"error", delErr, "plant", res.Plant.ID)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
res.Lot = lot
|
||||
return res, nil
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
func packet(species, variety, category string) vision.SeedPacket {
|
||||
return vision.SeedPacket{Species: species, Variety: variety, Category: category}
|
||||
}
|
||||
|
||||
func plant(id int64, name string) domain.Plant {
|
||||
return domain.Plant{ID: id, Name: name, Category: domain.CategoryVegetable}
|
||||
}
|
||||
|
||||
// TestMatchPlants pins the catalog-matching heuristic: it surfaces candidates,
|
||||
// best first, and never invents a match — the whole point, since a wrong auto-
|
||||
// match would fragment a variety's seed-lot history across duplicate rows.
|
||||
func TestMatchPlants(t *testing.T) {
|
||||
catalog := []domain.Plant{
|
||||
plant(1, "Garlic"),
|
||||
plant(2, "Music Garlic"),
|
||||
plant(3, "Cherokee Purple"),
|
||||
plant(4, "Basil"),
|
||||
}
|
||||
|
||||
t.Run("exact variety wins, ranked above looser matches", func(t *testing.T) {
|
||||
got := matchPlants(packet("tomato", "Cherokee Purple", "vegetable"), catalog)
|
||||
if len(got) == 0 || got[0].Plant.ID != 3 || got[0].Reason != "exact name" {
|
||||
t.Fatalf("want Cherokee Purple exact first, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("variety within a name, plus same-species, ordered", func(t *testing.T) {
|
||||
// "Music" garlic: "Music Garlic" contains the variety (rank 1); "Garlic"
|
||||
// shares the species word (rank 2).
|
||||
got := matchPlants(packet("garlic", "Music", "vegetable"), catalog)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Plant.ID != 2 || got[0].Reason != "variety in name" {
|
||||
t.Errorf("first = %+v, want Music Garlic / variety in name", got[0])
|
||||
}
|
||||
if got[1].Plant.ID != 1 || got[1].Reason != "same species" {
|
||||
t.Errorf("second = %+v, want Garlic / same species", got[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case-insensitive", func(t *testing.T) {
|
||||
got := matchPlants(packet("", "cherokee purple", ""), catalog)
|
||||
if len(got) == 0 || got[0].Plant.ID != 3 {
|
||||
t.Errorf("case-insensitive exact match failed: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match → empty (a new variety)", func(t *testing.T) {
|
||||
if got := matchPlants(packet("okra", "Clemson Spineless", "vegetable"), catalog); len(got) != 0 {
|
||||
t.Errorf("want no candidates, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("species word boundary, not substring", func(t *testing.T) {
|
||||
// "garlic" should not match a hypothetical "garlicky" — wordIn is token-based.
|
||||
got := matchPlants(packet("garlic", "", ""), []domain.Plant{plant(9, "Garlicky Mustard")})
|
||||
if len(got) != 0 {
|
||||
t.Errorf("substring shouldn't match on species: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// visionTestService builds a service with a configured vision model and a canned
|
||||
// extractor, so ExtractSeedPacket can run with no live model.
|
||||
func visionTestService(t *testing.T, out vision.SeedPacket, extractErr error) (*Service, int64) {
|
||||
t.Helper()
|
||||
cfg := openConfig()
|
||||
cfg.Agent.OllamaCloudAPIKey = "k"
|
||||
cfg.Agent.VisionModel = "ollama-cloud/vision:cloud"
|
||||
s := newTestService(t, cfg)
|
||||
s.extractPacket = func(ctx context.Context, apiKey, model string, jpeg []byte) (vision.SeedPacket, error) {
|
||||
return out, extractErr
|
||||
}
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
return s, owner
|
||||
}
|
||||
|
||||
// TestExtractSeedPacket exercises the orchestration: canned packet → proposal
|
||||
// with catalog candidates + prefill suggestions.
|
||||
func TestExtractSeedPacket(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, packet("garlic", "Music", "vegetable"), nil)
|
||||
// Seed a matching plant.
|
||||
if _, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||
Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed plant: %v", err)
|
||||
}
|
||||
|
||||
prop, err := s.ExtractSeedPacket(ctx, owner, []byte("jpeg-bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if prop.Packet.Variety != "Music" {
|
||||
t.Errorf("packet variety = %q", prop.Packet.Variety)
|
||||
}
|
||||
if len(prop.Candidates) == 0 || prop.Candidates[0].Plant.Name != "Music Garlic" {
|
||||
t.Errorf("expected Music Garlic candidate, got %+v", prop.Candidates)
|
||||
}
|
||||
if prop.SuggestedName != "Music" || prop.SuggestedCategory != domain.CategoryVegetable {
|
||||
t.Errorf("suggestions = %q/%q", prop.SuggestedName, prop.SuggestedCategory)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractSeedPacketNeedsVisionModel: with no vision model configured, the
|
||||
// feature is unavailable (ErrInvalidInput), and the extractor is never called.
|
||||
func TestExtractSeedPacketNeedsVisionModel(t *testing.T) {
|
||||
s := newTestService(t, openConfig()) // no vision model, no key
|
||||
called := false
|
||||
s.extractPacket = func(ctx context.Context, _, _ string, _ []byte) (vision.SeedPacket, error) {
|
||||
called = true
|
||||
return vision.SeedPacket{}, nil
|
||||
}
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
if _, err := s.ExtractSeedPacket(context.Background(), owner, []byte("x")); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("extractor was called despite no configured vision model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketNewPlant: a confirm with NewPlant creates the plant and a
|
||||
// lot attributed to it, in one call.
|
||||
func TestCreateFromPacketNewPlant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
|
||||
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||
NewPlant: &PlantInput{Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
||||
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: domain.UnitBulbs},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if !res.PlantIsNew || res.Plant.Name != "Music Garlic" {
|
||||
t.Errorf("plant = %+v, isNew=%v", res.Plant, res.PlantIsNew)
|
||||
}
|
||||
if res.Lot == nil || res.Lot.PlantID != res.Plant.ID {
|
||||
t.Errorf("lot not attributed to the new plant: %+v", res.Lot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketExistingPlant: a confirm with PlantID attaches the lot to
|
||||
// the existing plant and creates nothing new.
|
||||
func TestCreateFromPacketExistingPlant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
existing, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||
Name: "Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed plant: %v", err)
|
||||
}
|
||||
|
||||
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||
PlantID: &existing.ID,
|
||||
Lot: SeedLotInput{Vendor: "Fedco", Quantity: 10, Unit: domain.UnitBulbs},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if res.PlantIsNew || res.Plant.ID != existing.ID {
|
||||
t.Errorf("should attach to existing plant, got %+v isNew=%v", res.Plant, res.PlantIsNew)
|
||||
}
|
||||
if res.Lot.PlantID != existing.ID {
|
||||
t.Errorf("lot plantId = %d, want %d", res.Lot.PlantID, existing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketRollsBackNewPlantOnLotFailure: if the lot fails after a new
|
||||
// plant was created for the confirm, the plant is rolled back so a bad lot can't
|
||||
// strand a half-made catalog entry the user never asked for on its own.
|
||||
func TestCreateFromPacketRollsBackNewPlantOnLotFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
|
||||
before, err := s.ListPlants(ctx, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("list before: %v", err)
|
||||
}
|
||||
|
||||
// A bogus unit makes CreateSeedLot fail AFTER the plant is created.
|
||||
_, err = s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||
NewPlant: &PlantInput{Name: "Rollback Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
||||
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: "furlongs"},
|
||||
})
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("err = %v, want ErrInvalidInput from the bad unit", err)
|
||||
}
|
||||
|
||||
after, err := s.ListPlants(ctx, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("list after: %v", err)
|
||||
}
|
||||
if len(after) != len(before) {
|
||||
t.Errorf("plant count %d → %d: the rolled-back plant was left behind", len(before), len(after))
|
||||
}
|
||||
for _, p := range after {
|
||||
if p.Name == "Rollback Garlic" {
|
||||
t.Errorf("plant %q survived a failed lot; rollback didn't fire", p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketExactlyOne: both or neither of PlantID/NewPlant is refused,
|
||||
// so an ambiguous confirm can't silently pick.
|
||||
func TestCreateFromPacketExactlyOne(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
id := int64(1)
|
||||
for _, in := range []PacketConfirm{
|
||||
{}, // neither
|
||||
{PlantID: &id, NewPlant: &PlantInput{Name: "X"}}, // both
|
||||
} {
|
||||
if _, err := s.CreateFromPacket(ctx, owner, in); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("CreateFromPacket(%+v) err = %v, want ErrInvalidInput", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
// timeLayout is the ISO-8601 UTC format used for every full timestamp pansy
|
||||
@@ -43,35 +41,16 @@ type Service struct {
|
||||
// produced by timingHash (fixed salt, no RNG) so it is always present — an
|
||||
// empty one would silently re-open account enumeration.
|
||||
dummyHash string
|
||||
// extractPacket reads a photographed seed packet (#81). Injectable so tests
|
||||
// can supply a canned packet instead of calling a live vision model — the
|
||||
// same reason `now` is injectable. Defaults to vision.Extract.
|
||||
extractPacket func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)
|
||||
}
|
||||
|
||||
// Option customizes a Service at construction. The only current use is injecting
|
||||
// a seed-packet extractor in tests so they don't call a live vision model.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithPacketExtractor overrides how a photographed seed packet is read (#81).
|
||||
// Production uses vision.Extract; a test supplies a canned reader.
|
||||
func WithPacketExtractor(fn func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)) Option {
|
||||
return func(s *Service) { s.extractPacket = fn }
|
||||
}
|
||||
|
||||
// New constructs a Service.
|
||||
func New(st *store.DB, cfg *config.Config, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
store: st,
|
||||
cfg: cfg,
|
||||
now: time.Now,
|
||||
dummyHash: timingHash(),
|
||||
extractPacket: vision.Extract,
|
||||
func New(st *store.DB, cfg *config.Config) *Service {
|
||||
return &Service{
|
||||
store: st,
|
||||
cfg: cfg,
|
||||
now: time.Now,
|
||||
dummyHash: timingHash(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatTime renders a time as pansy's canonical UTC string.
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// The instance_settings row is seeded by migration 0010 and there is exactly one
|
||||
// (CHECK id = 1), so reads never branch on existence and writes never insert.
|
||||
|
||||
const instanceSettingsColumns = `agent_model, agent_enabled, vision_model, version, updated_at`
|
||||
|
||||
// scanInstanceSettings reads the single settings row. agent_enabled is a nullable
|
||||
// INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64.
|
||||
func scanInstanceSettings(s scanner) (*domain.InstanceSettings, error) {
|
||||
var (
|
||||
out domain.InstanceSettings
|
||||
enabled sql.NullInt64
|
||||
)
|
||||
if err := s.Scan(&out.AgentModel, &enabled, &out.VisionModel, &out.Version, &out.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if enabled.Valid {
|
||||
b := enabled.Int64 != 0
|
||||
out.AgentEnabled = &b
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetInstanceSettings returns the single settings row.
|
||||
func (d *DB) GetInstanceSettings(ctx context.Context) (*domain.InstanceSettings, error) {
|
||||
s, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
||||
`SELECT `+instanceSettingsColumns+` FROM instance_settings WHERE id = 1`))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// The migration seeds this row, so its absence is a broken database, not a
|
||||
// normal "not found" the caller should paper over.
|
||||
return nil, fmt.Errorf("store: instance_settings row missing (migration 0010 not applied?)")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: get instance settings: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// UpdateInstanceSettings applies a version-guarded update to the single row,
|
||||
// following the same optimistic-concurrency contract as every mutable resource:
|
||||
// the updated row on success, (current row, ErrVersionConflict) on a version
|
||||
// mismatch. There is no ErrNotFound path — the row always exists.
|
||||
//
|
||||
// agentEnabled is nil to store SQL NULL (inherit env), or a pointer to store an
|
||||
// explicit 0/1.
|
||||
func (d *DB) UpdateInstanceSettings(ctx context.Context, s *domain.InstanceSettings) (*domain.InstanceSettings, error) {
|
||||
var enabled any
|
||||
if s.AgentEnabled != nil {
|
||||
enabled = boolToInt(*s.AgentEnabled)
|
||||
}
|
||||
updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
||||
`UPDATE instance_settings
|
||||
SET agent_model = ?, agent_enabled = ?, vision_model = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 1 AND version = ?
|
||||
RETURNING `+instanceSettingsColumns,
|
||||
s.AgentModel, enabled, s.VisionModel, s.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
current, gerr := d.GetInstanceSettings(ctx)
|
||||
if gerr != nil {
|
||||
return nil, gerr
|
||||
}
|
||||
return current, domain.ErrVersionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: update instance settings: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
-- Instance settings (#79): the first configuration that lives in the database
|
||||
-- rather than the environment.
|
||||
--
|
||||
-- Until now every preference hung off a garden or object row; this is pansy's
|
||||
-- first INSTANCE-level state. A single-row table (CHECK id = 1) is the least
|
||||
-- surprising shape for "there is exactly one of these" — a key/value table would
|
||||
-- invite typo'd keys and lose the column types.
|
||||
--
|
||||
-- Only NON-SECRET agent settings live here. OLLAMA_CLOUD_API_KEY stays in the
|
||||
-- environment on purpose: a copy in SQLite would land in every backup and in the
|
||||
-- blast radius of the undo history. An admin can change WHICH model runs, not
|
||||
-- WHOSE account pays for it.
|
||||
--
|
||||
-- Both agent columns are "inherit from env unless set":
|
||||
-- * agent_model = '' means fall back to PANSY_AGENT_MODEL, then the built-in
|
||||
-- default. So an instance that never opens Settings behaves exactly as it
|
||||
-- did before this migration, and the documented env var keeps working.
|
||||
-- * agent_enabled is NULLABLE: NULL means inherit PANSY_AGENT_ENABLED's
|
||||
-- behaviour (on when a key is present), 0/1 is an explicit override. A plain
|
||||
-- boolean couldn't tell "admin hasn't touched this" from "admin turned it
|
||||
-- off", and those must deploy differently.
|
||||
--
|
||||
-- version drives the same optimistic-concurrency 409 every other mutable row
|
||||
-- uses, so two admins editing at once conflict rather than clobber.
|
||||
CREATE TABLE instance_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
agent_model TEXT NOT NULL DEFAULT '',
|
||||
agent_enabled INTEGER CHECK (agent_enabled IN (0, 1)),
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Seed the single row so every read is a plain SELECT with no "does it exist
|
||||
-- yet" branch. Inherits everything from the environment out of the box.
|
||||
INSERT INTO instance_settings (id, agent_model, agent_enabled) VALUES (1, '', NULL);
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Vision model setting (#81): the model that reads a photographed seed packet.
|
||||
--
|
||||
-- Separate from agent_model because it's a different capability — extracting
|
||||
-- structured fields from an image needs a vision-capable model, which the chat
|
||||
-- model may not be. Same "inherit from env unless set" contract as agent_model:
|
||||
-- '' falls back to PANSY_VISION_MODEL, then the feature is simply not offered.
|
||||
--
|
||||
-- Like agent_model, the API KEY is NOT stored here — the vision model runs
|
||||
-- against the same OLLAMA_CLOUD_API_KEY from the environment.
|
||||
ALTER TABLE instance_settings ADD COLUMN vision_model TEXT NOT NULL DEFAULT '';
|
||||
@@ -119,9 +119,7 @@ func (d *DB) ListChangeSets(ctx context.Context, gardenID int64, limit, offset i
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// One grouped query for the whole page rather than N per-row counts. The
|
||||
// service's countRevisions mirrors this grouping for change sets it has just
|
||||
// written; TestRevertResultCarriesItsCounts holds the two in step.
|
||||
// One grouped query for the whole page rather than N per-row counts.
|
||||
countRows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT change_set_id, entity_type, op, COUNT(*)
|
||||
FROM revisions
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// Package vision reads a photographed seed packet into structured fields (#81).
|
||||
//
|
||||
// It is one-shot structured extraction, NOT an agent loop: majordomo.Generate[T]
|
||||
// derives a JSON schema from the SeedPacket struct, hands the image to a vision
|
||||
// model, and unmarshals the reply into SeedPacket. Because it can't call a tool,
|
||||
// it can't touch the garden — it only reads a picture and returns data, which the
|
||||
// service then turns into a plant + lot after the user confirms.
|
||||
package vision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||
)
|
||||
|
||||
// SeedPacket is what a vision model reads off a packet. The json/description/enum
|
||||
// tags drive the schema majordomo.Generate derives; pointer fields are nullable,
|
||||
// so a field the packet doesn't print comes back nil rather than a made-up zero.
|
||||
//
|
||||
// These are the packet's PRINTED facts. Mapping them onto a pansy Plant + SeedLot
|
||||
// (and deciding whether the variety is one already in the catalog) is the
|
||||
// service's job, not the model's.
|
||||
type SeedPacket struct {
|
||||
Species string `json:"species" description:"the plant species in plain words, e.g. tomato, garlic, basil"`
|
||||
Variety string `json:"variety" description:"the cultivar or variety name, e.g. Cherokee Purple; empty if the packet only names a species"`
|
||||
Category string `json:"category" enum:"vegetable,herb,flower,fruit,tree_shrub,cover" description:"the single best-fit category"`
|
||||
Vendor string `json:"vendor" description:"the seed company, e.g. Johnny's Selected Seeds"`
|
||||
SKU string `json:"sku" description:"the vendor's item/product number, if printed"`
|
||||
LotCode string `json:"lotCode" description:"the lot or batch code, if printed"`
|
||||
PackedForYear *int `json:"packedForYear" description:"the 'packed for' or 'sell by' year, if printed"`
|
||||
DaysToMaturity *int `json:"daysToMaturity" description:"days to maturity/harvest, if printed"`
|
||||
SpacingCM *float64 `json:"spacingCm" description:"recommended in-row spacing in CENTIMETERS; convert if the packet uses inches"`
|
||||
SeedCount *int `json:"seedCount" description:"approximate seed count in the packet, if printed"`
|
||||
}
|
||||
|
||||
// extractPrompt tells the model the conventions it can't guess: centimeters, and
|
||||
// that a missing field must be left empty rather than invented.
|
||||
const extractPrompt = `You are reading a photograph of a seed packet. Extract only what is actually printed on it.
|
||||
Rules:
|
||||
- Spacing must be in CENTIMETERS. If the packet gives inches, convert (1 in = 2.54 cm).
|
||||
- If a field is not printed on the packet, leave it empty or null. Do not guess or fill from general knowledge.
|
||||
- "variety" is the cultivar name (e.g. "Cherokee Purple"); "species" is the plain plant name (e.g. "tomato").`
|
||||
|
||||
// Extract runs one vision extraction: it resolves the model spec against pansy's
|
||||
// registry, sends the JPEG with the prompt, and returns the parsed SeedPacket.
|
||||
// The image bytes should already be normalized to JPEG (see internal/imagenorm).
|
||||
//
|
||||
// It makes a live model call, so callers give it a bounded context.
|
||||
func Extract(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (SeedPacket, error) {
|
||||
if len(jpeg) == 0 {
|
||||
return SeedPacket{}, fmt.Errorf("vision: empty image")
|
||||
}
|
||||
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
||||
if err != nil {
|
||||
return SeedPacket{}, err
|
||||
}
|
||||
return generate(ctx, model, jpeg)
|
||||
}
|
||||
|
||||
// generate makes the actual one-shot call against an already-resolved model. It
|
||||
// is split from Extract on purpose: the hermetic test drives THIS function with a
|
||||
// fake model, so the prompt, the derived schema and the image part it builds are
|
||||
// the real ones the live path uses — not a hand-copied double that could drift.
|
||||
func generate(ctx context.Context, model llm.Model, jpeg []byte) (SeedPacket, error) {
|
||||
return majordomo.Generate[SeedPacket](ctx, model, majordomo.Request{
|
||||
Messages: []majordomo.Message{
|
||||
majordomo.UserParts(
|
||||
majordomo.Text(extractPrompt),
|
||||
majordomo.Image("image/jpeg", jpeg),
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
)
|
||||
|
||||
// tinyJPEG returns a real, sniffable JPEG. The chain runs media.Normalize before
|
||||
// the provider, which checks the image's magic bytes, so a string literal won't
|
||||
// do — the bytes must actually be a JPEG.
|
||||
func tinyJPEG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// TestExtractParsesModelJSON is the hermetic proof that the extraction path works
|
||||
// end to end without a live model: a fake vision model returns canned packet JSON
|
||||
// and Generate[SeedPacket] unmarshals it into the struct, image and schema
|
||||
// included.
|
||||
func TestExtractParsesModelJSON(t *testing.T) {
|
||||
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||
fp := fake.New("fp") // default caps advertise structured output + images
|
||||
reg.RegisterProvider(fp)
|
||||
fp.Enqueue("vision", fake.Reply(`{
|
||||
"species": "garlic",
|
||||
"variety": "Music",
|
||||
"category": "vegetable",
|
||||
"vendor": "Johnny's",
|
||||
"sku": "2761",
|
||||
"lotCode": "L-42",
|
||||
"packedForYear": 2026,
|
||||
"daysToMaturity": 240,
|
||||
"spacingCm": 15,
|
||||
"seedCount": 8
|
||||
}`))
|
||||
|
||||
m, err := reg.Parse("fp/vision")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
got, err := generate(context.Background(), m, tinyJPEG(t))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if got.Variety != "Music" || got.Species != "garlic" || got.Category != "vegetable" {
|
||||
t.Errorf("unexpected packet: %+v", got)
|
||||
}
|
||||
if got.SpacingCM == nil || *got.SpacingCM != 15 {
|
||||
t.Errorf("spacingCm = %v, want 15", got.SpacingCM)
|
||||
}
|
||||
if got.PackedForYear == nil || *got.PackedForYear != 2026 {
|
||||
t.Errorf("packedForYear = %v, want 2026", got.PackedForYear)
|
||||
}
|
||||
|
||||
// The image and the derived schema really reached the model.
|
||||
call := fp.Calls()[0]
|
||||
if call.Request.SchemaName != "seedpacket" {
|
||||
t.Errorf("schema name = %q, want seedpacket", call.Request.SchemaName)
|
||||
}
|
||||
var sawImage bool
|
||||
for _, p := range call.Request.Messages[0].Parts {
|
||||
if _, ok := p.(llm.ImagePart); ok {
|
||||
sawImage = true
|
||||
}
|
||||
}
|
||||
if !sawImage {
|
||||
t.Error("the image part didn't reach the model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractLeavesMissingFieldsNil: a packet that only prints a species comes
|
||||
// back with nil pointers for the numbers, not invented zeros — the whole reason
|
||||
// the numeric fields are pointers.
|
||||
func TestExtractLeavesMissingFieldsNil(t *testing.T) {
|
||||
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||
fp := fake.New("fp")
|
||||
reg.RegisterProvider(fp)
|
||||
fp.Enqueue("vision", fake.Reply(`{"species":"basil","category":"herb"}`))
|
||||
m, _ := reg.Parse("fp/vision")
|
||||
|
||||
got, err := generate(context.Background(), m, tinyJPEG(t))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if got.SpacingCM != nil || got.DaysToMaturity != nil || got.PackedForYear != nil || got.SeedCount != nil {
|
||||
t.Errorf("missing numeric fields should be nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractRejectsEmptyImage: no bytes, no call.
|
||||
func TestExtractRejectsEmptyImage(t *testing.T) {
|
||||
if _, err := Extract(context.Background(), "k", "fp/vision", nil); err == nil {
|
||||
t.Error("Extract accepted an empty image")
|
||||
}
|
||||
}
|
||||
@@ -4,62 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%237a8a5e' stroke-width='2.75' stroke-linecap='round' stroke-linejoin='round'><path d='M7 20h10'/><path d='M10 20c5.5-2.5.8-6.4 3-10'/><path d='M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z'/><path d='M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z'/></svg>" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>%F0%9F%8C%B1</text></svg>" />
|
||||
<title>pansy</title>
|
||||
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Caprasimo&family=Figtree:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
<script>
|
||||
// Pansy theme — light is the stylesheet default; dark overrides the tokens on
|
||||
// <html>. A classic (blocking) script on purpose: it runs before the first
|
||||
// paint, so a dark-mode user never sees a cream flash. The preference
|
||||
// ('system' | 'light' | 'dark') persists in localStorage; src/lib/theme.ts is
|
||||
// the typed React face of this same object (window.PansyTheme) and this is
|
||||
// the ONLY place the dark token values live. Ported from
|
||||
// docs/design_handoff_pansy_ui/pansy-theme.js.
|
||||
(function () {
|
||||
var KEY = 'pansy-theme';
|
||||
var DARK = {
|
||||
'--color-bg': '#252220', '--color-surface': '#33302a', '--color-text': '#f1e9da',
|
||||
'--color-divider': 'color-mix(in srgb, #f5ead8 16%, transparent)',
|
||||
'--color-accent': '#d67f48',
|
||||
'--color-neutral-100': '#2e2b25', '--color-neutral-200': '#3a362f', '--color-neutral-300': '#474238',
|
||||
'--color-neutral-400': '#645c50', '--color-neutral-500': '#82796a', '--color-neutral-800': '#dcd3c4',
|
||||
'--color-accent-100': '#3d2c1d', '--color-accent-200': '#59331a', '--color-accent-300': '#8c491a',
|
||||
'--color-accent-400': '#d67f48', '--color-accent-700': '#f6a06b', '--color-accent-800': '#ffd9bd', '--color-accent-900': '#ffe9da',
|
||||
'--color-accent-2-200': '#333d24', '--color-accent-2-100': '#2d3520', '--color-accent-2-300': '#3d472b', '--color-accent-2-500': '#728157',
|
||||
'--color-accent-2-600': '#aebf92', '--color-accent-2-700': '#ccdbb2', '--color-accent-2-800': '#e1eecc',
|
||||
'--shadow-sm': '0 1px 2px rgba(0,0,0,0.4)', '--shadow-md': '0 3px 10px rgba(0,0,0,0.45)', '--shadow-lg': '0 12px 32px rgba(0,0,0,0.55)',
|
||||
'--p-field': '#2b2823', '--p-grid-ink': '#f5ead8',
|
||||
'--p-ink-strong': '#d9d0bf', '--p-ink-soft': '#b3a992', '--p-ink-mute': '#8f8674',
|
||||
'--p-bed-fill': '#4a4131', '--p-bed-stroke': '#6d5f47', '--p-ing-fill': '#3f382c', '--p-ing-stroke': '#5c5343',
|
||||
'--p-path-fill': '#312e28', '--p-path-stroke': '#4d473c', '--p-bag-fill': '#463d2f', '--p-bag-stroke': '#6a5d49',
|
||||
'--p-bkt-fill': '#3e382e', '--p-bkt-stroke': '#5f574a', '--p-tree-fill': '#333a28', '--p-tree-stroke': '#56633f',
|
||||
'--p-str-fill': '#3b362e', '--p-str-stroke': '#5f574a'
|
||||
};
|
||||
var P = {
|
||||
get: function () { try { return localStorage.getItem(KEY) || 'system'; } catch (e) { return 'system'; } },
|
||||
set: function (v) { try { localStorage.setItem(KEY, v); } catch (e) {} },
|
||||
sysDark: function () { return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); },
|
||||
isDark: function (pref) { return pref === 'dark' || (pref === 'system' && P.sysDark()); },
|
||||
apply: function (dark) {
|
||||
var r = document.documentElement.style;
|
||||
Object.keys(DARK).forEach(function (k) { if (dark) r.setProperty(k, DARK[k]); else r.removeProperty(k); });
|
||||
r.colorScheme = dark ? 'dark' : 'light';
|
||||
},
|
||||
watch: function (cb) {
|
||||
if (!window.matchMedia) return function () {};
|
||||
var m = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
m.addEventListener('change', cb);
|
||||
return function () { m.removeEventListener('change', cb); };
|
||||
},
|
||||
next: function (p) { return p === 'system' ? 'light' : p === 'light' ? 'dark' : 'system'; }
|
||||
};
|
||||
window.PansyTheme = P;
|
||||
P.apply(P.isDark(P.get()));
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -18,11 +18,10 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.14"
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Nav } from '@/components/layout/Nav'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { buttonClasses } from '@/components/ui/Button'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
|
||||
/** The router's catch-all for unknown paths. */
|
||||
export function NotFound() {
|
||||
usePageTitle('Not found')
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<Nav />
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-3 text-center">
|
||||
<Icon name="sprout" size={40} className="text-accent-2-500" />
|
||||
<h3>Nothing growing here</h3>
|
||||
<p className="text-[13px] text-ink-soft">That page doesn't exist — the link may be wrong or the page moved.</p>
|
||||
<Link to="/gardens" className="btn btn-primary mt-2 no-underline">
|
||||
Back to the gardens
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="text-5xl" aria-hidden>
|
||||
🌱
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold text-fg">Page not found</h1>
|
||||
<p className="text-sm text-muted">That page doesn't exist — the link may be wrong or the page moved.</p>
|
||||
<Link to="/gardens" className={buttonClasses('primary')}>
|
||||
Back to gardens
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Placeholder page scaffolding used until each feature issue fills these in. */
|
||||
export function PageStub({ title, children }: { title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
{children ?? 'Placeholder — this page arrives in a later issue.'}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,10 @@ import { errorMessage } from '@/lib/api'
|
||||
export function RouteError({ error }: { error: Error }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<h3>Something went wrong</h3>
|
||||
<p className="text-[13px] text-ink-soft">{errorMessage(error, 'An unexpected error occurred.')}</p>
|
||||
<Button variant="primary" className="mt-2" onClick={() => router.invalidate()}>
|
||||
Try again
|
||||
</Button>
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
|
||||
<h1 className="text-lg font-semibold text-fg">Something went wrong</h1>
|
||||
<p className="text-sm text-muted">{errorMessage(error, 'An unexpected error occurred.')}</p>
|
||||
<Button onClick={() => router.invalidate()}>Try again</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Centered card layout shared by the login and register pages. */
|
||||
export function AuthCard({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] w-full max-w-sm flex-col justify-center">
|
||||
<div className="rounded-xl border border-border bg-surface p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-fg">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
|
||||
<div className="mt-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ThemeButton } from '@/components/layout/ThemeButton'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
|
||||
/**
|
||||
* The sign-in/sign-up backdrop: a centered 400px column over two soft accent
|
||||
* circles, the brand mark above the card, the theme toggle in the corner.
|
||||
*/
|
||||
export function AuthScreen({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex min-h-dvh items-center justify-center overflow-hidden bg-bg p-6">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -right-[140px] -top-[180px] h-[520px] w-[520px] rounded-full bg-accent-2-200 opacity-55"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -bottom-[140px] -left-[100px] h-[340px] w-[340px] rounded-full bg-accent-200 opacity-50"
|
||||
/>
|
||||
<ThemeButton className="absolute right-[18px] top-[18px] bg-neutral-100" />
|
||||
<div className="relative flex w-[min(400px,100%)] flex-col gap-[22px]">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Icon name="sprout" size={44} className="text-accent-2-600" />
|
||||
<h1 className="text-[40px]">pansy</h1>
|
||||
<p className="text-[14.5px] text-ink-soft">Plan the plot. Keep the notes. Grow the thing.</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The card the forms sit in. */
|
||||
export function AuthCard({ children }: { children: ReactNode }) {
|
||||
return <div className="panel elev-md flex flex-col gap-3.5 p-[26px]">{children}</div>
|
||||
}
|
||||
|
||||
/** "— or —" between the two sign-in methods. */
|
||||
export function OrDivider() {
|
||||
return (
|
||||
<div className="my-0.5 flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-divider" />
|
||||
<span className="text-xs text-ink-mute">or</span>
|
||||
<span className="h-px flex-1 bg-divider" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { TextField } from '@/components/ui/Field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { useCopyGarden, useGardens, type Garden } from '@/lib/gardens'
|
||||
import { nextPlanYear, parsePlanName, planNameFor } from '@/lib/plan'
|
||||
|
||||
/**
|
||||
* Duplicate a garden — the way to scheme a season: the copy is a separate
|
||||
* garden you rearrange freely while this one stays put. Beds and everything
|
||||
* currently planted come along; the share link and shares don't. The name is
|
||||
* prefilled as "<name> — <year>" for the next year that doesn't already have a
|
||||
* plan, which is what the editor's season control and the `plan` tag read back
|
||||
* (see lib/plan.ts). On success we land in the copy, since the point of copying
|
||||
* is to start editing it.
|
||||
*/
|
||||
export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const copy = useCopyGarden()
|
||||
const navigate = useNavigate()
|
||||
const gardens = useGardens()
|
||||
const names = (gardens.data ?? []).map((g) => g.name)
|
||||
const base = parsePlanName(garden.name)?.base ?? garden.name
|
||||
const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
||||
const year = nextPlanYear(base, names, from)
|
||||
const [name, setName] = useState(() => planNameFor(base, year))
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// The gardens list can still be loading when this opens; until the person
|
||||
// edits the name, keep the proposal in step with what the list says is free.
|
||||
useEffect(() => {
|
||||
if (!touched) setName(planNameFor(base, year))
|
||||
}, [base, year, touched])
|
||||
// The API allows duplicate names; say so rather than let two gardens read as
|
||||
// the same season's plan.
|
||||
const taken = names.some((n) => n.trim() === name.trim())
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
const created = await copy.mutateAsync({ id: garden.id, name: name.trim() })
|
||||
toast.info(`Copied to “${created.name}”.`)
|
||||
onClose()
|
||||
navigate({ to: '/gardens/$gardenId', params: { gardenId: String(created.id) } })
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not copy the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Plan a season" onClose={onClose} busy={copy.isPending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||
<p className="text-[13px] leading-relaxed text-ink-soft">
|
||||
A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in — rearrange freely, the
|
||||
original stays put. Beds and what's planted come along; shares and the public link don't.
|
||||
</p>
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setTouched(true)
|
||||
setName(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-ink-mute">Keep the “— {year}” and it shows up as that season's plan in the editor.</p>
|
||||
{taken && <Alert tone="info">You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={copy.isPending}>
|
||||
Never mind
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={copy.isPending || name.trim() === ''}>
|
||||
{copy.isPending ? 'Copying…' : 'Make the copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { defaultCopyName, useCopyGarden, type Garden } from '@/lib/gardens'
|
||||
|
||||
/**
|
||||
* Duplicate a garden under a new name. The name is prefilled with the server's
|
||||
* own default so what you see is what you get; the beds and everything currently
|
||||
* planted in them come along, while the source's share link and shares do not.
|
||||
* On success we land in the copy — the point of copying is to start editing it.
|
||||
*/
|
||||
export function CopyGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const copy = useCopyGarden()
|
||||
const navigate = useNavigate()
|
||||
const [name, setName] = useState(() => defaultCopyName(garden.name))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
const created = await copy.mutateAsync({ id: garden.id, name: name.trim() })
|
||||
toast.info(`Copied to “${created.name}”.`)
|
||||
onClose()
|
||||
navigate({ to: '/gardens/$gardenId', params: { gardenId: String(created.id) } })
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not copy the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Copy garden" onClose={onClose} busy={copy.isPending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Make a copy of <span className="font-medium text-fg">{garden.name}</span> with its beds and
|
||||
everything planted in them. The copy is private — the original's share link and people you've
|
||||
shared it with aren't carried over.
|
||||
</p>
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={copy.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={copy.isPending || name.trim() === ''}>
|
||||
{copy.isPending ? 'Copying…' : 'Copy garden'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { useDeleteGarden, type Garden } from '@/lib/gardens'
|
||||
|
||||
/** Confirmation dialog for deleting a garden (and everything in it). */
|
||||
export function DeleteGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const deletion = useDeleteGarden()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onConfirm() {
|
||||
setError(null)
|
||||
try {
|
||||
await deletion.mutateAsync(garden.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not delete the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Delete garden" onClose={onClose} busy={deletion.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
|
||||
This can't be undone.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}>
|
||||
{deletion.isPending ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,14 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { IconButton } from '@/components/ui/Button'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { useGardenFull } from '@/lib/objects'
|
||||
import { parsePlanName, planYearOf } from '@/lib/plan'
|
||||
import { sharesQueryOptions } from '@/lib/shares'
|
||||
import { formatSize } from '@/lib/units'
|
||||
import { kindPlural } from '@/editor/kinds'
|
||||
import { GardenThumb } from './GardenThumb'
|
||||
|
||||
// The order kinds are counted in on the card's meta line.
|
||||
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
|
||||
import { formatDimensions } from '@/lib/units'
|
||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||
|
||||
/**
|
||||
* One garden as a card: the plot thumbnail (a link into the editor), name, size,
|
||||
* a counts line, who it's shared with, and a footer of Open + share / copy /
|
||||
* edit / delete. A plan copy shows its base name with a `<year> plan` tag. A
|
||||
* garden shared WITH you shows its role and a leave action instead of the
|
||||
* owner's tools.
|
||||
* One garden as a card: the body links into the editor. The footer differs by
|
||||
* role — the owner gets Share / Copy / Edit / Delete; a recipient sees a
|
||||
* "shared · role" badge and a Leave action (garden metadata edit, sharing and
|
||||
* copying are owner-only).
|
||||
* Ownership is the authoritative ownerId==me check, not the my_role hint.
|
||||
*/
|
||||
export function GardenCard({
|
||||
garden,
|
||||
@@ -39,79 +28,47 @@ export function GardenCard({
|
||||
onLeave: () => void
|
||||
}) {
|
||||
const owner = currentUserId != null && garden.ownerId === currentUserId
|
||||
const full = useGardenFull(garden.id)
|
||||
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
|
||||
const plan = parsePlanName(garden.name)
|
||||
const planYear = planYearOf(garden.name)
|
||||
// A plan's year is the point of its name, and the first thing truncation
|
||||
// would eat ("Back Yard — 20…"); show the base name and put the year on the tag.
|
||||
const title = planYear != null && plan ? plan.base : garden.name
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const data = full.data
|
||||
if (!data) return full.isError ? 'Could not load the plot.' : '…'
|
||||
if (data.objects.length === 0) return 'Bare ground — drag your first bed on.'
|
||||
const counts = new Map<string, number>()
|
||||
for (const o of data.objects) counts.set(o.kind, (counts.get(o.kind) ?? 0) + 1)
|
||||
const parts = COUNTED_KINDS.filter((k) => counts.has(k)).map((k) => kindPlural(k, counts.get(k)!))
|
||||
for (const [k, n] of counts) if (!COUNTED_KINDS.includes(k)) parts.push(kindPlural(k, n))
|
||||
const plops = data.plantings.length
|
||||
parts.push(`${plops} ${plops === 1 ? 'planting' : 'plantings'}`)
|
||||
const since = new Date(garden.createdAt).getFullYear()
|
||||
if (Number.isFinite(since)) parts.push(`tended since ${since}`)
|
||||
return parts.join(' · ')
|
||||
}, [full.data, full.isError, garden.createdAt])
|
||||
|
||||
const sharedLine = (() => {
|
||||
if (!owner) return garden.myRole ? `Shared with you · ${garden.myRole}` : 'Shared with you'
|
||||
const list = shares.data ?? []
|
||||
if (list.length === 0) return null
|
||||
const first = list[0]
|
||||
const who = first.email.includes('@') ? first.email.slice(0, first.email.indexOf('@') + 1) : first.displayName
|
||||
return list.length === 1 ? `Shared with ${who} · ${first.role}` : `Shared with ${who} +${list.length - 1}`
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="panel flex flex-col overflow-hidden transition-shadow hover:[box-shadow:var(--shadow-md)]">
|
||||
<div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50">
|
||||
<Link
|
||||
to="/gardens/$gardenId"
|
||||
params={{ gardenId: String(garden.id) }}
|
||||
className="block border-b border-divider bg-field no-underline"
|
||||
aria-label={`Open ${garden.name}`}
|
||||
className="flex-1 rounded-t-xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<GardenThumb widthCm={garden.widthCm} heightCm={garden.heightCm} full={full.data} />
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col gap-2 px-[18px] py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
|
||||
{title}
|
||||
</span>
|
||||
{planYear != null && <Tag tone="accent">{planYear} plan</Tag>}
|
||||
<span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute">
|
||||
{formatSize(garden.widthCm, garden.heightCm, garden.unitPref)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] leading-relaxed text-ink-soft">{meta}</div>
|
||||
{sharedLine && <div className="text-xs font-semibold text-accent-2-700">{sharedLine}</div>}
|
||||
<div className="mt-auto flex gap-2 pt-2">
|
||||
<Link
|
||||
to="/gardens/$gardenId"
|
||||
params={{ gardenId: String(garden.id) }}
|
||||
className="btn btn-primary flex-1 no-underline"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
{owner ? (
|
||||
<>
|
||||
<IconButton label="Share" icon="share-2" onClick={onShare} />
|
||||
<IconButton label="Copy — plan a season from it" icon="copy" onClick={onCopy} />
|
||||
<IconButton label="Edit the garden's name and size" icon="pencil" onClick={onEdit} />
|
||||
<IconButton label="Delete" icon="trash-2" onClick={onDelete} iconClassName="text-accent-700" />
|
||||
</>
|
||||
) : (
|
||||
<IconButton label="Leave this garden" icon="log-out" onClick={onLeave} iconClassName="text-accent-700" />
|
||||
<h3 className="truncate font-semibold text-fg">{garden.name}</h3>
|
||||
{!owner && garden.myRole && (
|
||||
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
|
||||
shared · {garden.myRole}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)}
|
||||
</p>
|
||||
{garden.notes && <p className="mt-2 line-clamp-2 text-sm text-muted">{garden.notes}</p>}
|
||||
</Link>
|
||||
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
{owner ? (
|
||||
<>
|
||||
<button type="button" onClick={onShare} className={cardActionClass}>
|
||||
Share
|
||||
</button>
|
||||
<button type="button" onClick={onCopy} className={cardActionClass}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" onClick={onEdit} className={cardActionClass}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={onLeave} className={cardDangerClass}>
|
||||
Leave
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { Seg } from '@/components/ui/Seg'
|
||||
import { Toggle } from '@/components/ui/Toggle'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
||||
import {
|
||||
cmFromFtIn,
|
||||
convertDimensionField,
|
||||
dimensionField,
|
||||
dimensionInputMode,
|
||||
dimensionUnitLabel,
|
||||
editDimensionField,
|
||||
formatCm,
|
||||
isValidDimensionCm,
|
||||
MIN_GARDEN_GRID_CM,
|
||||
type LengthField,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
|
||||
// A new plot: the design's 20′ × 12′, spoken in feet; the garden grid defaults
|
||||
// to a foot (the server's 1 m for a metric garden).
|
||||
const DEFAULT_W_FT = 20
|
||||
const DEFAULT_H_FT = 12
|
||||
const DEFAULT_GRID_CM = 100
|
||||
|
||||
const unitOptions = [
|
||||
{ value: 'imperial' as const, label: 'ft' },
|
||||
{ value: 'metric' as const, label: 'm' },
|
||||
]
|
||||
|
||||
function entryHint(unit: UnitPref): string {
|
||||
return unit === 'imperial' ? `Sizes read as feet and inches — 8' 6", 8', or 8.5 for feet.` : 'Sizes are in meters, e.g. 2.5.'
|
||||
}
|
||||
|
||||
/**
|
||||
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
|
||||
* chosen unit and stored as centimeters: each field is a LengthField, so
|
||||
* switching units re-shows the same centimeters and a Save sends exactly what
|
||||
* was loaded unless the person typed over it (re-parsing the display string is
|
||||
* how 900 cm once became 899.922). A 409 rebases the form onto the server's
|
||||
* fresh row.
|
||||
*/
|
||||
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
|
||||
const isEdit = !!garden
|
||||
const create = useCreateGarden()
|
||||
const update = useUpdateGarden()
|
||||
const pending = create.isPending || update.isPending
|
||||
|
||||
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
|
||||
const [name, setName] = useState(garden?.name ?? '')
|
||||
const [unit, setUnit] = useState<UnitPref>(initialUnit)
|
||||
const [width, setWidth] = useState<LengthField>(() => dimensionField(garden?.widthCm ?? cmFromFtIn(DEFAULT_W_FT), initialUnit))
|
||||
const [height, setHeight] = useState<LengthField>(() => dimensionField(garden?.heightCm ?? cmFromFtIn(DEFAULT_H_FT), initialUnit))
|
||||
const [gridSize, setGridSize] = useState<LengthField>(() =>
|
||||
dimensionField(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
|
||||
)
|
||||
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
|
||||
const [notes, setNotes] = useState(garden?.notes ?? '')
|
||||
const [more, setMore] = useState(isEdit && (!!garden.notes || garden.snapToGrid))
|
||||
const [version, setVersion] = useState(garden?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
function changeUnit(next: UnitPref) {
|
||||
setWidth((f) => convertDimensionField(f, next))
|
||||
setHeight((f) => convertDimensionField(f, next))
|
||||
setGridSize((f) => convertDimensionField(f, next))
|
||||
setUnit(next)
|
||||
}
|
||||
|
||||
const gridSizeCm = gridSize.cm
|
||||
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setFormError(null)
|
||||
setConflict(null)
|
||||
if (!name.trim()) {
|
||||
setFormError('Give the garden a name.')
|
||||
return
|
||||
}
|
||||
const widthCm = width.cm
|
||||
const heightCm = height.cm
|
||||
if (widthCm === null || heightCm === null) {
|
||||
setFormError(entryHint(unit))
|
||||
return
|
||||
}
|
||||
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
|
||||
setFormError('Width and depth must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
if (gridSizeCm === null || !isValidDimensionCm(gridSizeCm)) {
|
||||
setFormError('The grid must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid }
|
||||
// Nothing changed: close without a request. A PATCH that writes the same
|
||||
// row still bumps the version and lands an "Edited garden settings" step
|
||||
// in History that undoes nothing.
|
||||
if (isEdit && (Object.keys(input) as (keyof typeof input)[]).every((k) => input[k] === garden[k])) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version })
|
||||
else await create.mutateAsync(input)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const current = conflictGarden(err)
|
||||
if (current) {
|
||||
setVersion(current.version)
|
||||
setName(current.name)
|
||||
setUnit(current.unitPref)
|
||||
setWidth(dimensionField(current.widthCm, current.unitPref))
|
||||
setHeight(dimensionField(current.heightCm, current.unitPref))
|
||||
setGridSize(dimensionField(current.gridSizeCm, current.unitPref))
|
||||
setSnapToGrid(current.snapToGrid)
|
||||
setNotes(current.notes)
|
||||
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
|
||||
return
|
||||
}
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save the garden.' : 'Could not create the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
const u = dimensionUnitLabel(unit)
|
||||
const inputMode = dimensionInputMode(unit)
|
||||
|
||||
return (
|
||||
<Dialog title={isEdit ? `Edit ${garden.name}` : 'A new garden'} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
<TextField label="Name" name="name" required autoFocus placeholder="Back forty" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<div className="flex gap-2.5">
|
||||
<TextField
|
||||
label={`Width (${u})`}
|
||||
name="width"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={width.text}
|
||||
onChange={(e) => setWidth(editDimensionField(e.target.value, unit))}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
<TextField
|
||||
label={`Depth (${u})`}
|
||||
name="height"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={height.text}
|
||||
onChange={(e) => setHeight(editDimensionField(e.target.value, unit))}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
<div className="field">
|
||||
<label>Units</label>
|
||||
<Seg options={unitOptions} value={unit} onChange={changeUnit} label="Units" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-ink-mute">Stored in centimeters under the hood — {unit === 'imperial' ? 'feet are' : 'meters are'} just how you talk.</div>
|
||||
|
||||
{!more ? (
|
||||
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
|
||||
Grid & notes…
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
|
||||
<div className="flex items-end gap-2.5">
|
||||
<TextField
|
||||
label={`Garden grid (${u})`}
|
||||
name="gridSize"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={gridSize.text}
|
||||
onChange={(e) => setGridSize(editDimensionField(e.target.value, unit))}
|
||||
wrapperClassName="flex-1"
|
||||
hint={
|
||||
gridTooFine && gridSizeCm !== null
|
||||
? `${formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid — plant spacing lives on each bed.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="field">
|
||||
<label>Snap objects</label>
|
||||
<Toggle on={snapToGrid} onChange={setSnapToGrid} label="Snap objects to the garden grid" />
|
||||
</div>
|
||||
</div>
|
||||
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={pending}>
|
||||
Never mind
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Break ground'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
||||
import {
|
||||
dimensionUnitLabel,
|
||||
formatCm,
|
||||
dimensionInputMode,
|
||||
formatDimensionInput,
|
||||
isValidDimensionCm,
|
||||
MIN_GARDEN_GRID_CM,
|
||||
parseDimension,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
|
||||
const DEFAULT_METERS = 10 // matches the server's 10 m default
|
||||
const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default
|
||||
|
||||
// What to say when a field can't be read. Naming the accepted forms beats
|
||||
// "invalid input", which leaves the person guessing which field and which part.
|
||||
function entryHint(unit: UnitPref): string {
|
||||
return unit === 'imperial'
|
||||
? `Enter sizes as feet and inches — 8' 6", 8', or 8.5 for feet.`
|
||||
: 'Enter sizes in meters, e.g. 2.5.'
|
||||
}
|
||||
|
||||
const unitOptions = [
|
||||
{ value: 'metric', label: 'Metric (m)' },
|
||||
{ value: 'imperial', label: 'Imperial (ft)' },
|
||||
]
|
||||
|
||||
function dimString(cm: number | undefined, unit: UnitPref): string {
|
||||
return cm === undefined ? String(DEFAULT_METERS) : formatDimensionInput(cm, unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (no garden) or edit (garden given) form. Dimensions are entered in the
|
||||
* selected unit and converted to centimeters for the API; switching units
|
||||
* converts the current values so the physical size is preserved. A 409 rebases
|
||||
* the form onto the server's fresh row.
|
||||
*/
|
||||
export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
|
||||
const isEdit = !!garden
|
||||
const create = useCreateGarden()
|
||||
const update = useUpdateGarden()
|
||||
const pending = create.isPending || update.isPending
|
||||
|
||||
const [name, setName] = useState(garden?.name ?? '')
|
||||
const [unit, setUnit] = useState<UnitPref>(garden?.unitPref ?? 'metric')
|
||||
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
|
||||
const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric'))
|
||||
const [gridSize, setGridSize] = useState(() =>
|
||||
formatDimensionInput(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric'),
|
||||
)
|
||||
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
|
||||
const [notes, setNotes] = useState(garden?.notes ?? '')
|
||||
const [version, setVersion] = useState(garden?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
function changeUnit(next: UnitPref) {
|
||||
// Re-render each field in the new unit, preserving the physical size. An
|
||||
// unparseable field is left as typed rather than blanked.
|
||||
const convert = (s: string) => {
|
||||
const cm = parseDimension(s, unit)
|
||||
return cm === null ? s : formatDimensionInput(cm, next)
|
||||
}
|
||||
setWidth(convert(width))
|
||||
setHeight(convert(height))
|
||||
// The garden grid is a layout concern, so it lives at the same scale as the
|
||||
// garden's own dimensions — same helpers, same unit label. (The *bed* grid in
|
||||
// the object inspector is a plant-spacing concern and stays at cm/in.)
|
||||
setGridSize(convert(gridSize))
|
||||
setUnit(next)
|
||||
}
|
||||
|
||||
// Converted once per render and used by both the submit handler and the
|
||||
// too-fine hint, so the two can never disagree about what was entered.
|
||||
const gridSizeCm = parseDimension(gridSize, unit)
|
||||
// Soft floor: hint, don't refuse. A garden-scale grid this fine is usually
|
||||
// someone reaching for plant spacing, which lives on the bed instead — but it
|
||||
// is a legitimate choice for a very small garden, so the save still goes through.
|
||||
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setFormError(null)
|
||||
setConflict(null)
|
||||
|
||||
if (!name.trim()) {
|
||||
setFormError('Enter a name for the garden.')
|
||||
return
|
||||
}
|
||||
// Validate the converted centimeter values against the same bounds the
|
||||
// server enforces, so sub-cm or over-100m sizes fail here with a clear
|
||||
// message instead of a generic server error.
|
||||
const widthCm = parseDimension(width, unit)
|
||||
const heightCm = parseDimension(height, unit)
|
||||
if (widthCm === null || heightCm === null) {
|
||||
setFormError(entryHint(unit))
|
||||
return
|
||||
}
|
||||
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
|
||||
setFormError('Width and height must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
if (gridSizeCm === null) {
|
||||
setFormError(entryHint(unit))
|
||||
return
|
||||
}
|
||||
if (!isValidDimensionCm(gridSizeCm)) {
|
||||
setFormError('Grid size must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
|
||||
const input = {
|
||||
name: name.trim(),
|
||||
widthCm,
|
||||
heightCm,
|
||||
unitPref: unit,
|
||||
notes: notes.trim(),
|
||||
gridSizeCm,
|
||||
snapToGrid,
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await update.mutateAsync({ id: garden.id, ...input, version })
|
||||
} else {
|
||||
await create.mutateAsync(input)
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const current = conflictGarden(err)
|
||||
if (current) {
|
||||
// Someone else changed this garden: rebase the form onto the fresh row so
|
||||
// a re-save applies against the current version.
|
||||
setVersion(current.version)
|
||||
setName(current.name)
|
||||
setUnit(current.unitPref)
|
||||
setWidth(dimString(current.widthCm, current.unitPref))
|
||||
setHeight(dimString(current.heightCm, current.unitPref))
|
||||
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
|
||||
setSnapToGrid(current.snapToGrid)
|
||||
setNotes(current.notes)
|
||||
setConflict('This garden changed elsewhere. The latest values are shown — review and save again.')
|
||||
return
|
||||
}
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
const unitLabel = dimensionUnitLabel(unit)
|
||||
const inputMode = dimensionInputMode(unit)
|
||||
|
||||
return (
|
||||
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
|
||||
<TextField label="Name" name="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
|
||||
<Select
|
||||
label="Units"
|
||||
name="unitPref"
|
||||
value={unit}
|
||||
onChange={(e) => changeUnit(e.target.value as UnitPref)}
|
||||
options={unitOptions}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label={`Width (${unitLabel})`}
|
||||
name="width"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={`Height (${unitLabel})`}
|
||||
name="height"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
label={`Garden grid (${unitLabel})`}
|
||||
name="gridSize"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={gridSize}
|
||||
onChange={(e) => setGridSize(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={snapToGrid}
|
||||
onChange={(e) => setSnapToGrid(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
Snap objects
|
||||
</label>
|
||||
</div>
|
||||
{gridTooFine && (
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid. Plant spacing lives on each bed
|
||||
(Bed grid in the inspector), not here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Create garden'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { localToWorld } from '@/lib/geometry'
|
||||
import type { FullGarden } from '@/lib/objects'
|
||||
import { FALLBACK_PLANT_COLOR } from '@/lib/plants'
|
||||
import { objectStyle, rectRadius } from '@/editor/kinds'
|
||||
|
||||
/**
|
||||
* The plot thumbnail on a garden card: the field, its objects at true layout,
|
||||
* and every active planting as a dot in its plant's color. Pure SVG from the
|
||||
* editor payload — the same data the editor opens with, so the card is an
|
||||
* honest preview and the editor then loads from cache.
|
||||
*/
|
||||
export function GardenThumb({
|
||||
widthCm,
|
||||
heightCm,
|
||||
full,
|
||||
}: {
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
/** Undefined while the payload loads: draws the bare field. */
|
||||
full?: FullGarden
|
||||
}) {
|
||||
const W = Math.max(1, widthCm)
|
||||
const H = Math.max(1, heightCm)
|
||||
const sw = Math.max(2, Math.min(8, Math.round(Math.max(W, H) / 120)))
|
||||
const inset = sw
|
||||
const plantColor = useMemo(() => new Map((full?.plants ?? []).map((p) => [p.id, p.color])), [full?.plants])
|
||||
const objects = useMemo(() => [...(full?.objects ?? [])].sort((a, b) => a.zIndex - b.zIndex), [full?.objects])
|
||||
const byId = useMemo(() => new Map(objects.map((o) => [o.id, o])), [objects])
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="block h-[150px] w-full" preserveAspectRatio="xMidYMid meet" aria-hidden>
|
||||
<rect
|
||||
x={inset}
|
||||
y={inset}
|
||||
width={W - inset * 2}
|
||||
height={H - inset * 2}
|
||||
rx={Math.min(18, W * 0.03)}
|
||||
fill="var(--p-field)"
|
||||
stroke="var(--p-tree-stroke)"
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
{objects.map((o) => {
|
||||
const st = objectStyle(o)
|
||||
const t = `translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`
|
||||
return o.shape === 'circle' ? (
|
||||
<ellipse
|
||||
key={o.id}
|
||||
transform={t}
|
||||
rx={o.widthCm / 2}
|
||||
ry={o.heightCm / 2}
|
||||
fill={st.fill}
|
||||
stroke={st.stroke}
|
||||
strokeWidth={sw * 0.6}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
key={o.id}
|
||||
transform={t}
|
||||
x={-o.widthCm / 2}
|
||||
y={-o.heightCm / 2}
|
||||
width={o.widthCm}
|
||||
height={o.heightCm}
|
||||
rx={rectRadius(o.widthCm)}
|
||||
fill={st.fill}
|
||||
stroke={st.stroke}
|
||||
strokeWidth={sw * 0.6}
|
||||
strokeDasharray={st.dash}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{(full?.plantings ?? []).map((p) => {
|
||||
const o = byId.get(p.objectId)
|
||||
if (!o) return null
|
||||
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
|
||||
return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? FALLBACK_PLANT_COLOR} />
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { useRemoveShare } from '@/lib/shares'
|
||||
|
||||
/** Confirmation for a recipient leaving a garden shared with them (removes their
|
||||
* own share). */
|
||||
export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const me = useMe()
|
||||
const remove = useRemoveShare(garden.id)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onConfirm() {
|
||||
if (!me.data) return
|
||||
setError(null)
|
||||
try {
|
||||
await remove.mutateAsync(me.data.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not leave the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Leave garden" onClose={onClose} busy={remove.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
|
||||
shares it with you again.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={remove.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={onConfirm} disabled={remove.isPending || !me.data}>
|
||||
{remove.isPending ? 'Leaving…' : 'Leave'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Toggle } from '@/components/ui/Toggle'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import {
|
||||
useAddShare,
|
||||
useDisableShareLink,
|
||||
useEnableShareLink,
|
||||
useRemoveShare,
|
||||
useShareLink,
|
||||
useShares,
|
||||
useUpdateShareRole,
|
||||
type ShareRole,
|
||||
} from '@/lib/shares'
|
||||
|
||||
/**
|
||||
* Owner-only: invite an existing account by email (new invites start as
|
||||
* viewers — tap the role chip to flip to editor), remove a share, and manage the
|
||||
* public read-only link. v1 has no invitation emails; an unknown address gets a
|
||||
* friendly "no account with that email".
|
||||
*/
|
||||
export function ShareDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const shares = useShares(garden.id)
|
||||
const add = useAddShare(garden.id)
|
||||
const updateRole = useUpdateShareRole(garden.id)
|
||||
const remove = useRemoveShare(garden.id)
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const busy = add.isPending || updateRole.isPending || remove.isPending
|
||||
|
||||
async function onInvite(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
const addr = email.trim()
|
||||
if (!addr) return
|
||||
try {
|
||||
await add.mutateAsync({ email: addr, role: 'viewer' })
|
||||
setEmail('')
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not share the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
||||
|
||||
return (
|
||||
<Dialog title={`Share ${garden.name}`} onClose={onClose} busy={busy} width={440}>
|
||||
<form onSubmit={onInvite} className="flex gap-2">
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
aria-label="Invite by email"
|
||||
autoComplete="off"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="flex-none" disabled={add.isPending || !email.trim()}>
|
||||
{add.isPending ? 'Inviting…' : 'Invite'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
|
||||
{shares.isSuccess && shares.data.length === 0 && (
|
||||
<p className="text-[13px] text-ink-mute">Not shared with anyone yet — invites go to existing accounts.</p>
|
||||
)}
|
||||
{shares.data?.map((sh) => (
|
||||
<div key={sh.userId} className="flex items-center gap-2.5 rounded-full border border-divider bg-bg py-1.5 pl-4 pr-1.5">
|
||||
<span className="min-w-0 truncate text-[13px] font-semibold" title={`${sh.displayName} · ${sh.email}`}>
|
||||
{sh.email}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="tag tag-accent-2 ml-auto cursor-pointer border-0"
|
||||
title="Tap to switch between viewer and editor"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
const role: ShareRole = sh.role === 'viewer' ? 'editor' : 'viewer'
|
||||
updateRole.mutate({ userId: sh.userId, role }, { onError: onMutationError('Could not change that role.') })
|
||||
}}
|
||||
>
|
||||
{sh.role}
|
||||
</button>
|
||||
<IconButton
|
||||
label={`Remove ${sh.displayName}`}
|
||||
icon="x"
|
||||
iconSize={13}
|
||||
variant="plain"
|
||||
size={30}
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="hr my-0.5" />
|
||||
<PublicLinkSection gardenId={garden.id} />
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose}>Done</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/** The public read-only link: a toggle, the link itself (tap to copy), and a
|
||||
* way to issue a fresh one that invalidates the old. */
|
||||
function PublicLinkSection({ gardenId }: { gardenId: number }) {
|
||||
const link = useShareLink(gardenId)
|
||||
const enable = useEnableShareLink(gardenId)
|
||||
const disable = useDisableShareLink(gardenId)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const token = link.data?.enabled ? link.data.token : undefined
|
||||
const url = token ? `${window.location.origin}/g/${token}` : ''
|
||||
const busy = link.isPending || enable.isPending || disable.isPending
|
||||
|
||||
const run = (p: Promise<unknown>, fallback: string) => {
|
||||
setError(null)
|
||||
p.catch((err) => setError(errorMessage(err, fallback)))
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// Clipboard may be unavailable (non-secure context); the text is selectable.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] font-semibold">Read-only link</span>
|
||||
<span className="text-xs text-ink-mute">anyone with it can look, no account needed</span>
|
||||
<Toggle
|
||||
className="ml-auto"
|
||||
label="Public read-only link"
|
||||
on={!!link.data?.enabled}
|
||||
disabled={busy}
|
||||
onChange={(on) =>
|
||||
on ? run(enable.mutateAsync({}), 'Could not create the link.') : run(disable.mutateAsync(), 'Could not turn off the link.')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{link.isError && <Alert>Could not load the public link.</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{url && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title="Copy the link"
|
||||
className="min-w-0 flex-1 cursor-pointer truncate rounded-full border border-dashed border-divider bg-bg px-3.5 py-2 text-left text-xs text-ink-soft hover:border-accent-400"
|
||||
>
|
||||
{copied ? 'Copied to the clipboard' : url}
|
||||
</button>
|
||||
<IconButton label="Copy the link" icon="copy" iconSize={14} onClick={copy} />
|
||||
<IconButton
|
||||
label="Issue a new link (the old one stops working)"
|
||||
icon="refresh-cw"
|
||||
iconSize={14}
|
||||
disabled={busy}
|
||||
onClick={() => run(enable.mutateAsync({ rotate: true }), 'Could not regenerate the link.')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fieldControlClass } from '@/components/ui/field'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import {
|
||||
useAddShare,
|
||||
useDisableShareLink,
|
||||
useEnableShareLink,
|
||||
useRemoveShare,
|
||||
useShareLink,
|
||||
useShares,
|
||||
useUpdateShareRole,
|
||||
type ShareRole,
|
||||
} from '@/lib/shares'
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'viewer', label: 'Viewer (read-only)' },
|
||||
{ value: 'editor', label: 'Editor (can edit)' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Owner-only dialog to manage a garden's shares: invite an existing user by
|
||||
* email as viewer/editor, change a share's role, or remove it. Targets existing
|
||||
* accounts only (v1 has no invitation emails) — an unknown email surfaces a
|
||||
* friendly "no account with that email".
|
||||
*/
|
||||
export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const shares = useShares(garden.id)
|
||||
const add = useAddShare(garden.id)
|
||||
const updateRole = useUpdateShareRole(garden.id)
|
||||
const remove = useRemoveShare(garden.id)
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<ShareRole>('viewer')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onInvite(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (!email.trim()) {
|
||||
setError('Enter an email address.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await add.mutateAsync({ email: email.trim(), role })
|
||||
setEmail('')
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not share the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
||||
|
||||
return (
|
||||
<Modal title="Share garden" onClose={onClose} busy={add.isPending || updateRole.isPending || remove.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={onInvite} className="flex flex-col gap-2">
|
||||
<TextField
|
||||
label="Invite by email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-end gap-2">
|
||||
<Select
|
||||
label="Role"
|
||||
name="role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as ShareRole)}
|
||||
options={roleOptions}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={add.isPending}>
|
||||
{add.isPending ? 'Sharing…' : 'Share'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-fg">Shared with</h3>
|
||||
{shares.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
|
||||
{shares.isSuccess && shares.data.length === 0 && (
|
||||
<p className="text-sm text-muted">Not shared with anyone yet.</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{shares.data?.map((sh) => (
|
||||
<li key={sh.userId} className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-fg">{sh.displayName}</p>
|
||||
<p className="truncate text-xs text-muted">{sh.email}</p>
|
||||
</div>
|
||||
<select
|
||||
value={sh.role}
|
||||
onChange={(e) => {
|
||||
setError(null)
|
||||
updateRole.mutate(
|
||||
{ userId: sh.userId, role: e.target.value as ShareRole },
|
||||
{ onError: onMutationError('Could not change that role.') },
|
||||
)
|
||||
}}
|
||||
aria-label={`Role for ${sh.displayName}`}
|
||||
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
|
||||
}}
|
||||
aria-label={`Remove ${sh.displayName}`}
|
||||
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<PublicLinkSection gardenId={garden.id} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** The public read-only link controls: create, copy, regenerate, turn off. */
|
||||
function PublicLinkSection({ gardenId }: { gardenId: number }) {
|
||||
const link = useShareLink(gardenId)
|
||||
const enable = useEnableShareLink(gardenId)
|
||||
const disable = useDisableShareLink(gardenId)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const token = link.data?.enabled ? link.data.token : undefined
|
||||
const url = token ? `${window.location.origin}/g/${token}` : ''
|
||||
const busy = link.isPending || enable.isPending || disable.isPending
|
||||
|
||||
const run = (p: Promise<unknown>, fallback: string) => {
|
||||
setError(null)
|
||||
p.catch((err) => setError(errorMessage(err, fallback)))
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// Clipboard API may be unavailable (e.g. non-secure context); the field is
|
||||
// selectable so the user can still copy manually.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="mb-1 text-sm font-medium text-fg">Public link</h3>
|
||||
<p className="mb-2 text-xs text-muted">
|
||||
Anyone with the link can view this garden read-only — no account needed.
|
||||
</p>
|
||||
|
||||
{link.isError && <Alert>Could not load the public link.</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
{link.isSuccess && !link.data.enabled && (
|
||||
<Button onClick={() => run(enable.mutateAsync({}), 'Could not create the link.')} disabled={busy}>
|
||||
{enable.isPending ? 'Creating…' : 'Create public link'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{link.isSuccess && link.data.enabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
aria-label="Public link URL"
|
||||
className={cn(fieldControlClass, 'min-w-0 flex-1 text-sm')}
|
||||
/>
|
||||
<Button variant="ghost" onClick={copy} disabled={!url}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => run(enable.mutateAsync({ rotate: true }), 'Could not regenerate the link.')}
|
||||
disabled={busy}
|
||||
title="Issue a new link and invalidate the old one"
|
||||
>
|
||||
{enable.isPending ? 'Working…' : 'Regenerate'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||||
onClick={() => run(disable.mutateAsync(), 'Could not turn off the link.')}
|
||||
disabled={busy}
|
||||
>
|
||||
Turn off
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { useLogout, type User } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/** The avatar circle in the nav (the user's initial on sage) and its small
|
||||
* sign-out popover. */
|
||||
export function AccountMenu({ user, className }: { user: User; className?: string }) {
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
// Close on any route change so navigating can't leave the popover stuck open.
|
||||
useEffect(() => setOpen(false), [pathname])
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await logout.mutateAsync()
|
||||
await navigate({ to: '/login' })
|
||||
} catch {
|
||||
// Keep the popover open so "Retry sign out" stays reachable.
|
||||
}
|
||||
}
|
||||
|
||||
const initial = user.displayName.trim().charAt(0).toUpperCase() || '·'
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`Account: ${user.displayName}`}
|
||||
title={user.displayName}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-accent-2-300 text-sm font-bold text-accent-2-800"
|
||||
>
|
||||
{initial}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<button type="button" aria-label="Close menu" className="fixed inset-0 z-30 cursor-default" onClick={() => setOpen(false)} />
|
||||
<div role="menu" className="panel elev-md absolute right-0 z-40 mt-2 w-56 !rounded-[18px] p-2">
|
||||
<p className="truncate px-3 py-2 text-xs text-ink-mute">
|
||||
Signed in as <span className="font-semibold text-text">{user.displayName}</span>
|
||||
<br />
|
||||
<span className="text-[11px]">{user.email}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onLogout}
|
||||
disabled={logout.isPending}
|
||||
className="btn btn-ghost w-full justify-start gap-2 px-3 text-[13px]"
|
||||
>
|
||||
<Icon name="log-out" size={14} />
|
||||
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,89 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
import { useLogout, useMe } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* The root layout is deliberately empty chrome: every page renders its own nav
|
||||
* (the editor wants a different one on a phone than Gardens does), so the shell
|
||||
* only provides the Suspense boundary for the code-split routes and the toast
|
||||
* stack. The theme is applied to <html> by the bootstrap in index.html.
|
||||
*/
|
||||
const navLinks = [
|
||||
{ to: '/gardens', label: 'Gardens' },
|
||||
{ to: '/plants', label: 'Plants' },
|
||||
] as const
|
||||
|
||||
// TanStack Router concatenates the base className with activeProps/inactiveProps,
|
||||
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
|
||||
// state props — never in the base — to avoid ambiguous overrides.
|
||||
const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors'
|
||||
const navLinkActive = 'bg-border/60 text-fg'
|
||||
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
|
||||
|
||||
/** Top-level chrome: a sticky nav bar plus the routed page in an <Outlet>. */
|
||||
export function AppShell() {
|
||||
const me = useMe()
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
const user = me.data
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await logout.mutateAsync()
|
||||
await navigate({ to: '/login' })
|
||||
} catch {
|
||||
// The logout request failed, so the session is still valid server-side:
|
||||
// leave the user where they are (the button re-enables for a retry) rather
|
||||
// than pretending they're signed out. logout.isError drives the title below.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
|
||||
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3">
|
||||
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
||||
🌱 pansy
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
{user &&
|
||||
navLinks.map((l) => (
|
||||
<Link
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
className={navLinkBase}
|
||||
activeProps={{ className: navLinkActive }}
|
||||
inactiveProps={{ className: navLinkInactive }}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
disabled={logout.isPending}
|
||||
title={logout.isError ? 'Sign out failed — try again' : undefined}
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg disabled:opacity-60"
|
||||
>
|
||||
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<Toaster />
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageFallback({ label = 'Loading…' }: { label?: string }) {
|
||||
return <p className="p-7 text-[13px] font-semibold text-ink-mute">{label}</p>
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { AccountMenu } from './AccountMenu'
|
||||
import { ThemeButton } from './ThemeButton'
|
||||
|
||||
export type NavSection = 'gardens' | 'plants' | 'settings'
|
||||
|
||||
/** The brand mark: sage sprout + "pansy" in the display face. */
|
||||
export function Brand({ size = 19, textClassName }: { size?: number; textClassName?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 font-heading text-lg text-text">
|
||||
<Icon name="sprout" size={size} className="text-accent-2-600" />
|
||||
<span className={textClassName}>pansy</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The top nav shared by the Gardens, Plants and Settings pages (and the editor
|
||||
* on desktop): brand, centered section links, and the right cluster — theme
|
||||
* toggle, the settings gear for admins, the account avatar.
|
||||
*/
|
||||
export function Nav({ active }: { active?: NavSection }) {
|
||||
const me = useMe()
|
||||
const user = me.data
|
||||
const link = (to: '/gardens' | '/plants', id: NavSection, label: string) => (
|
||||
<Link
|
||||
to={to}
|
||||
aria-current={active === id ? 'page' : undefined}
|
||||
className={cn('text-sm text-text no-underline hover:text-accent', active === id && 'text-accent')}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
return (
|
||||
<nav className="flex flex-none items-center gap-[17.6px] px-[17.6px] py-[13.2px]">
|
||||
<Link to="/gardens" className="mr-auto no-underline">
|
||||
<Brand />
|
||||
</Link>
|
||||
{link('/gardens', 'gardens', 'Gardens')}
|
||||
{link('/plants', 'plants', 'Plants')}
|
||||
<span className="ml-auto flex items-center gap-2.5">
|
||||
<ThemeButton />
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
to="/settings"
|
||||
aria-current={active === 'settings' ? 'page' : undefined}
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
className={cn('btn btn-icon btn-secondary', active === 'settings' && 'bg-accent-100 text-accent')}
|
||||
>
|
||||
<Icon name="settings" />
|
||||
</Link>
|
||||
)}
|
||||
{user && <AccountMenu user={user} />}
|
||||
</span>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { IconButton } from '@/components/ui/Button'
|
||||
import type { IconName } from '@/components/ui/Icon'
|
||||
import { cycleThemePref, useThemePref, type ThemePref } from '@/lib/theme'
|
||||
|
||||
const ICON: Record<ThemePref, IconName> = { system: 'monitor', light: 'sun', dark: 'moon' }
|
||||
|
||||
/** The nav's theme control: one button cycling system → light → dark. */
|
||||
export function ThemeButton({ size, iconSize, className }: { size?: number; iconSize?: number; className?: string }) {
|
||||
const pref = useThemePref()
|
||||
return (
|
||||
<IconButton
|
||||
label={`Theme: ${pref}`}
|
||||
icon={ICON[pref]}
|
||||
iconSize={iconSize}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={cycleThemePref}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { CATEGORY_LABELS, PLANT_CATEGORIES, type CategoryFilter } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* Horizontal, scrollable "All + each category" chip row. Shared by the /plants
|
||||
* page and the PlantPicker so both filter the catalog identically.
|
||||
*/
|
||||
export function CategoryChips({
|
||||
value,
|
||||
onChange,
|
||||
size = 'md',
|
||||
}: {
|
||||
value: CategoryFilter
|
||||
onChange: (c: CategoryFilter) => void
|
||||
size?: 'sm' | 'md'
|
||||
}) {
|
||||
const chip = (v: CategoryFilter, label: string) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onChange(v)}
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-3 font-medium transition-colors',
|
||||
size === 'sm' ? 'py-1 text-xs' : 'py-1 text-sm',
|
||||
value === v ? 'bg-accent text-accent-contrast' : 'bg-border/50 text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
return (
|
||||
<div className="flex gap-1.5 overflow-x-auto">
|
||||
{chip('all', 'All')}
|
||||
{PLANT_CATEGORIES.map((c) => chip(c, CATEGORY_LABELS[c]))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// Six curated marker colors from the design palette; the seventh well is a
|
||||
// native color input for anything else.
|
||||
export const CURATED_SWATCHES = ['#97a97c', '#c8553d', '#5f8f45', '#d9912f', '#b2622d', '#6d7f5a']
|
||||
|
||||
/** Expand #rgb to #rrggbb so the native color input renders it. */
|
||||
export function expandHex(color: string, fallback = CURATED_SWATCHES[0]): string {
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(color)) {
|
||||
const [, r, g, b] = color
|
||||
return `#${r}${r}${g}${g}${b}${b}`
|
||||
}
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : fallback
|
||||
}
|
||||
|
||||
export function ColorSwatches({ value, onChange }: { value: string; onChange: (hex: string) => void }) {
|
||||
const custom = !CURATED_SWATCHES.includes(value.toLowerCase())
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{CURATED_SWATCHES.map((hex) => (
|
||||
<button
|
||||
key={hex}
|
||||
type="button"
|
||||
title={hex}
|
||||
aria-label={`Marker color ${hex}`}
|
||||
aria-pressed={value.toLowerCase() === hex}
|
||||
onClick={() => onChange(hex)}
|
||||
className={cn('h-[30px] w-[30px] rounded-full border-[3px]', value.toLowerCase() === hex ? 'border-accent' : 'border-transparent')}
|
||||
style={{ background: hex }}
|
||||
/>
|
||||
))}
|
||||
<label
|
||||
title="Any other color"
|
||||
className={cn(
|
||||
'relative grid h-[30px] w-[30px] cursor-pointer place-items-center overflow-hidden rounded-full border-[3px]',
|
||||
custom ? 'border-accent' : 'border-transparent',
|
||||
)}
|
||||
style={{ background: custom ? value : 'conic-gradient(#c8553d, #d9912f, #97a97c, #5f8f45, #6d7f5a, #b2622d, #c8553d)' }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Custom marker color"
|
||||
value={expandHex(value)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { useDeletePlant, type Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* Confirmation dialog for deleting a custom plant. A plant still used by
|
||||
* plantings is refused by the server (409 PLANT_IN_USE); we surface that
|
||||
* message inline rather than pretending it worked.
|
||||
*/
|
||||
export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) {
|
||||
const deletion = useDeletePlant()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onConfirm() {
|
||||
setError(null)
|
||||
try {
|
||||
await deletion.mutateAsync(plant.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not delete the plant.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Delete plant" onClose={onClose} busy={deletion.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
|
||||
undone.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}>
|
||||
{deletion.isPending ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
||||
|
||||
/**
|
||||
* Retire a lot. Worth confirming because it's the one place cost and germination
|
||||
* data lives — and worth saying plainly that the plantings survive it, since
|
||||
* "will this wipe my garden" is the reasonable fear.
|
||||
*/
|
||||
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
|
||||
const del = useDeleteSeedLot()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-fg">
|
||||
{formatQuantity(lot.quantity)} {lot.unit}
|
||||
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
||||
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={del.isPending}
|
||||
onClick={() =>
|
||||
del.mutate(lot.id, {
|
||||
onSuccess: onClose,
|
||||
onError: (err) => setError(errorMessage(err, 'Could not retire the lot.')),
|
||||
})
|
||||
}
|
||||
>
|
||||
{del.isPending ? 'Retiring…' : 'Retire lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||