- PublicGardenPage had the same 100vh mobile bug I fixed in the editor — 100dvh
there too, so the fix is consistent across both full-height pages.
- Cap the toast stack at 4 (drop oldest). Now that error toasts don't auto-
dismiss, a burst of failures could otherwise grow the stack unbounded and push
the newest — the one that just happened — off-screen.
- Hoist the From/To date-input styling to a shared dateInputClass const so the
two don't drift.
Not taken: reusing safeRedirectPath for the 401 redirect (it validates an
incoming redirect param, it doesn't build the outgoing one — the two uses don't
overlap), and refactoring the three-branch filter spread (it's clear at three
fields; a helper earns its keep when there are more).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
The safe, self-contained wins from the #85 bundle:
- **Mobile viewport.** The editor used `100vh`, which on mobile Safari/Chrome is
the LARGEST viewport (URL bar hidden), so with the bar visible the canvas
bottom and Fit button were pushed under the browser chrome. `100dvh` fixes it.
- **Session expiry mid-chat.** A 401 during a chat turn was mapped to "the
assistant is not available right now" — sending the user to debug a config
problem that isn't there. Now it says the session expired and redirects to
/login, preserving the path, like the rest of the app treats 401.
- **Error toasts persist.** Error toasts were the primary report that a mutation
failed, yet auto-dismissed at 4s with no way to retrieve them — look away and
it's gone. Errors now stay until dismissed; info toasts still time out; both
get a close button.
- **Journal date filter.** `from`/`to` existed in the API and JournalFilter but
had no UI, so "show me last spring" was unreachable. Added two date inputs
(with a Clear) that feed the existing filter. No backend change.
Deferred to their own follow-ups (too big for this bundle, or need a decision):
revision-history retention/pruning, the agent toolbox's missing corrective tools,
plop-level journal entries from the UI, and the editor's max-width cap (a layout
call worth confirming rather than changing blind). The DESIGN.md API-listing
drift the issue mentioned was already fixed in #89.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Closes#79. Agent model + on/off move into an admin-only Settings section, and
is_admin is enforced for the first time. The live Runner is hot-swapped behind an
atomic.Pointer with routes always registered, so a settings change takes effect
with no restart; /capabilities reads the pointer. Secrets stay in the env, never
the DB. Precedence: Settings → env → default. Verified live: swap on/off/model,
bad spec → 400, race-clean. Gadfly blocking round addressed (dead code removed,
error-swallowing fixed, frontend 503 handling, holder dedup).
- Remove config.AgentConfig.Ready() — dead after the refactor (no non-test
callers), and its doc comment ("route registration and capabilities gate on
this") was now false. EffectiveAgent.Ready() carries the same logic and IS
used, see below.
- agentHolder now uses eff.Ready() and eff.APIKey instead of an inline check and
a redundant apiKey field it held separately. This makes EffectiveAgent.APIKey/
Ready() production-used rather than test-only, drops the duplicated readiness
check, and simplifies newAgentHolder's signature.
- settingsPayload no longer swallows an EffectiveAgent error into a misleading
empty "effective" view (which would read as "nothing configured"). It returns
the error; the handlers surface it as a 500. EffectiveAgent re-reads the row
GetInstanceSettings just returned, so a failure there is a real DB fault.
- Frontend streamChat handles 503 (assistant disabled at runtime) distinctly
from 404 (endpoint absent) — the backend returns 503 AGENT_DISABLED now, which
the old code mislabelled.
- Fixed the api.go comment that still said a disabled request gets a 404 — it's
a 503.
- updateSettings uses the shared parseNullable[bool] instead of a bespoke
parseNullableBool (now removed).
- NewRunner drops its empty-spec guard; agentmodel.Resolve already owns that
check, so one place decides what a valid spec is.
- rebuild-on-resolve-error now documents WHY it keeps the current Runner rather
than tearing down a working assistant on a transient DB blip: the state is
persisted, the next rebuild reconciles, and killing a live assistant on a read
hiccup is worse than a brief stale window.
Not changed: the store's version-conflict fallback returning GetInstanceSettings'
error instead of ErrVersionConflict when that read also fails. That's the exact
pattern every other version-guarded update uses (gardens/objects/plants); making
only this one differ would be the inconsistency. A DB read failing immediately
after the guarded UPDATE on the same local SQLite file is a disk-fault edge case,
and surfacing that error is defensible.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
- Rename fillRequest → objectFillRequest to match the package's
<resource><Action>Request convention (objectCreateRequest, gardenCreateRequest…).
- Add the missing anonymous-clear permission case (401), mirroring anonymous fill.
- Reword the makeFillPlant comment to stop referencing external branch state,
which won't make sense once merged — just note the consolidation follow-up.
- clearObject: send no body (undefined) rather than an empty {}, and validate
the {cleared} response with a zod schema instead of a raw type cast.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
- Reject a degenerate (zero-area or inverted) fill rect with 400 instead of
silently planting a single plop at the object centre. An empty `"rect": {}`
decodes to all-zeros and is caught the same way. New fillRect.degenerate()
and a table test covering {}, zero-width, zero-height, and inverted.
- Make the rect a named type (fillRect) rather than an anonymous inline struct,
matching the rest of internal/api, and bind the pointer to a local in the
handler so the deref is visibly guarded rather than reading req.Rect.MinX
under an invariant from a line above.
- ops_test: drop gid from the unpack instead of the `_ = gid` shim.
- ClearBedModal: drop the `const n = plopCount` no-op alias.
Not taken: moving createPlantAPI/makeFillPlant to a shared helper — that
consolidation spans this branch and #88 and is cleanest once both land, as
noted in the PR. Reusing an objectPlantingsPath helper: there isn't one in this
package, and the one inline use doesn't earn a new helper on its own.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.
Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
first instance-level state. Holds agent_model ('' = inherit env) and
agent_enabled (NULL = inherit env), version-guarded like every mutable row.
SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
nil-check agent.get(), so a settings change turns the assistant on/off/onto a
new model with no restart and no race against in-flight readers. /capabilities
reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
to turn a spec into a model. Both agent (to run) and service (to validate a
spec before storing it) import it; it can't live in agent, which imports
service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
rather than a broken assistant on the next turn.
Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
a running page — and the settings save invalidates it.
Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.
Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.
Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
FillRegion and ClearObject were reachable only through the agent toolbox, so
on an instance with no model configured 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. internal/service/ops.go said these lived
on *Service so "any future REST surface" would inherit the ACL checks; this is
that surface.
POST /objects/:id/fill takes a region EITHER by compass name ("all", "ne",
"south half") or as an explicit rect in the object's local frame, and refuses
both-or-neither: silently preferring one would make a client bug look like a
geometry bug. It answers 200 rather than 201 because a fill can legitimately
create nothing (the region is already planted) and there is no single resource
to point a Location at.
POST /objects/:id/clear replaces a client-side loop of PATCHes. That loop
wrote one change set per plop, so clearing a 40-plop bed took 40 presses of
Undo to put back — while the agent's clear_object, for the identical
user-facing action, undid in one click. CLAUDE.md states the rule it violated:
multi-row operations record together so they undo as one unit. Moving it
server-side also removes the partial-failure case the loop had to reconcile.
TestClearObjectIsOneChangeSetAPI pins that: fill a bed, count change sets,
clear it, assert the count rose by exactly one.
DESIGN.md's API block gains the two new routes, and the four it was already
missing: GET/POST/DELETE /gardens/:id/share-link and the unauthenticated
GET /public/gardens/:token. An unauthenticated surface absent from the
architecture doc is the one worth fixing on sight.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Closes#44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details.
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Per-garden public read-only link: unauthenticated GET /api/v1/public/gardens/:token (no requireAuth, no OIDC), owner-only enable/rotate/disable, and a /g/$token page rendering GardenCanvas read-only. Review fixes: redact plant owner ids, Cache-Control: no-store, 400 on malformed body, shared resetTransient store action.
Closes#41.
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Use the full Plant the picker returns (fixes the silent placement failure) and add a per-garden Seed Tray for quick repeat placing. Review fixes: disarm on tray-remove, cap load, consolidate toolbar guards, rename interface, tidy.
Closes#39.
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Fixes from the PR #29 adversarial review (considered; not graded).
Performance / correctness
- ObjectShape is memo'd, so a pan/zoom (which only mutates the world <g>
transform) no longer re-renders every object.
- 'circle' objects render as an <ellipse> (rx/ry), honoring both dims — a
circle when width == height — instead of ignoring heightCm.
- The 1 m grid now actually fades in (opacity ramps as cells grow past the
visibility threshold), matching its description.
- Unique SVG pattern id (useId) so multiple canvases can't collide.
- The canvas re-fits when the garden id changes (not just once), so #11
switching gardens reframes.
Robustness
- geometry.zoomToFitRect takes rect size by magnitude; wheel/pinch ignore
non-finite deltas; ObjectShape clamps dimensions to >= 0 (no invalid SVG).
Maintainability
- Renamed the Size params from `viewport` to `canvasSize` (4 models); moved
easeInOutCubic/lerp into geometry.ts; renamed toLocal → clientToCanvas;
named constants for the label/corner factors; DEFAULT_FILL const;
EditorGarden.unitPref imports UnitPref; the Fit control uses the shared
Button. focusedObjectId/plantable are intentionally reserved for #11/#15.
tsc + 17 vitest tests green; re-verified in a browser (ellipses render,
unique pattern id, wheel zoom + Fit).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
The editor's technically-riskiest slice, built against mock data so #11
only adds interactions on top.
- lib/geometry.ts (+ vitest): world<->screen and object-local<->world
transforms, clampScale, zoomViewportAt (the wheel/pinch "keep the point
under the cursor stationary" math), zoomToFitRect. 11 geometry tests
(round-trips, rotation, cursor-anchored zoom, fit) + 6 units tests.
- editor/store.ts: Zustand store for ephemeral editor state — viewport,
selection, focusedObjectId (selection interactions grow in #11).
- editor/useViewport.ts: @use-gesture wiring — wheel + pinch zoom toward
the pointer, drag-pan, scale clamped to [0.05, 20] px/cm, and an animated
fitToRect (eased rAF tween). touch-action:none so the browser doesn't
fight gestures.
- editor/ObjectShape.tsx: rect/circle centered + rotated, kind default
colors, name label; non-scaling strokes so outlines stay 1px at any zoom.
- editor/GardenCanvas.tsx: full-size svg, single world <g>, fading 1m grid,
garden boundary, z-ordered objects; ResizeObserver-driven auto-fit, a
zoom readout, and a Fit control. Deps: zustand, @use-gesture/react,
vitest (+ test scripts).
- GardenEditorPage renders the canvas with a mock scene (#11 swaps in
/full).
Verified in a real browser: wheel zoom keeps the world point under the
cursor stationary (sub-cm drift on a 12m garden), a real drag pans, and
Fit animates to frame the garden. tsc + vitest green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Fixes from the PR #27 adversarial review (considered; not graded).
Correctness / error-handling
- Modal: focus once on mount and attach the keydown listener once (latest
onClose/busy via refs), so a parent re-render (e.g. a background refetch)
no longer re-fires focus() and steals it from the input being typed in.
- Modal takes a `busy` prop; while a save/delete is in flight, Escape and
backdrop clicks don't dismiss it (form/delete modals pass busy=pending),
so an action can't be interrupted mid-flight and lose its error.
- Form validates the *converted* centimeter values against the server's
[1cm, 100m] bounds, so sub-cm / over-100m sizes fail client-side with a
clear message instead of a generic server error.
- displayFromCm rounds imperial to 0.1 ft so an 8 ft garden (244 cm)
prefills as "8", not "8.01".
Maintainability
- Extracted ui/field.ts (useFieldId + fieldControlClass) shared by
TextField/TextArea/Select. Added a `danger` Button variant, used by the
delete modal; card edit/delete buttons gained focus-visible rings.
- useUpdateGarden takes the id in its mutation variables (no dummy 0 during
create). GardensPage shares a close handler; dropped a redundant zod
annotation; 409 rebase reuses dimString; renamed `del` -> `deletion`.
Verified in a browser: 8 ft round-trips to a prefilled "8"; a sub-cm value
is rejected client-side with the modal staying open. tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
The /gardens page is now home base, backed by the #7 API.
- lib/units.ts: cm <-> meters/feet conversion + formatCm/formatDimensions
(metric "m", imperial "ft/in"). Minimal for #8; #9's geometry lib
consolidates.
- lib/gardens.ts: zod gardenSchema + useGardens/useCreateGarden/
useUpdateGarden/useDeleteGarden (mutations invalidate the list) and
conflictGarden() to pull the fresh row out of a 409.
- GardensPage: loading/empty/error states; empty state prompts a first
garden; responsive card grid (single column on phones).
- GardenCard: name, dimensions formatted per the garden's unit, notes
preview; body links into /gardens/:id, edit/delete kept out of the link.
- GardenFormModal: create/edit with a unit selector; dimensions are entered
in the chosen unit and converted to cm (switching units converts the
current values). A 409 rebases the form onto the server's fresh row.
- DeleteGardenModal: confirmation before removing.
- ui/: Modal (Escape/backdrop close), TextArea, Select.
Verified in a real browser against the embedded binary: create appears
without reload; edit persists (version 2), form prefilled from stored cm
converted back to the garden's unit; delete confirms then removes -> empty
state; an imperial 4 ft x 8 ft garden stores 122 x 244 cm and shows
"4' 0" x 8' 0"". tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Fixes from the PR #25 adversarial review (graded 23 real / 5 false positive):
Error handling
- onLogout wraps mutateAsync in try/catch: a failed logout no longer
becomes an unhandled rejection; the user stays put (session still valid)
and the button offers "Retry sign out" (5 models flagged this).
- Root errorComponent (RouteError): a non-401 /auth/me failure in a
beforeLoad guard now shows a recoverable "Try again" screen instead of
blanking.
- Forms drop noValidate, restoring native required/type=email/minLength
checks before hitting the server.
Correctness
- RegisterPage gates on providers.isPending/isError before rendering, so
the form no longer briefly appears (submittable) on an SSO-only server.
- TextField id falls back to name then a useId() value, so the label/hint
associations hold even if a caller omits both.
Maintainability
- Single safeRedirectPath (lib/redirect.ts) replaces the duplicated
safeRedirect/safeInternalPath (4 models).
- Generic ApiError helpers (apiErrorCode, errorMessage) moved to lib/api.ts;
LoginPage builds the OIDC URL from the exported API_BASE.
- Divider moved to components/ui/. errorMessage doc clarified.
Verified again in a real browser: register -> /gardens; Sign out -> /login.
tsc --noEmit and vite build clean.
Not changed (graded, with rationale): OIDC deep-link redirect isn't
preserved (needs backend state threading — follow-up); 60s me staleTime in
guards (server is authoritative); the login/register onSubmit catches are
the intended react-query pattern (errors render via mutation state).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Frontend for pansy auth, rendering whatever /auth/providers reports.
- lib/auth.ts: zod-validated User/Providers, a shared meQueryOptions
(a 401 resolves to null, not an error) reused by useMe() and the router
guard, useProviders(), and useLogin/useRegister/useLogout mutations that
prime/clear the me cache (logout also drops non-auth cached data).
- lib/queryClient.ts: one QueryClient shared by the React tree and the
router context so guard and components hit the same cache.
- router.tsx: createRootRouteWithContext with the queryClient; requireAuth
(redirects to /login?redirect=<dest>) on gardens/plants/editor, and
requireGuest on login/register (bounces authed users away). Login search
params (redirect, error) validated as optional; redirect targets are
restricted to same-origin paths.
- pages/LoginPage: OIDC button (label from providers) linking to
/auth/oidc/login, "or" divider, local email/password form, and friendly
messages for the OIDC callback ?error= codes. RegisterPage: email +
display name + password (min 8), registration-closed and SSO-only
handling; auto-login lands on /gardens.
- AppShell: auth-aware nav — shows the user's display name + Sign out when
logged in, Sign in otherwise; nav links only when authed.
- ui/: TextField (16px text to avoid iOS zoom, autocomplete + a11y),
Button (+ shared buttonClasses for the OIDC anchor), Alert; AuthCard.
Verified in a real browser against the embedded binary: register →
auto-login → /gardens; refresh stays in; Sign out → /login; a logged-out
/gardens/1 redirects to /login and returns after login; OIDC button renders
with the Authentik label when configured. tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Applied the fixes warranted by the adversarial review of PR #21 (all
findings graded in the gadfly store):
Store (highest impact):
- buildDSN always merges busy_timeout/journal_mode/foreign_keys pragmas
into the DSN, even for file: URIs or paths with a query string — the
prior passthrough silently disabled FK enforcement for those PANSY_DB
values. Also escape '#' in the path and tighten in-memory detection to
an exact ':memory:' token or mode=memory param (no substring misfire).
- migrate.go: detect duplicate migration versions with a clear error;
wrap appliedVersions' rows.Err() with the store: prefix.
API:
- SetTrustedProxies failure now falls back to trust-none instead of
leaving gin's trust-everyone default (X-Forwarded-For spoofing).
- SPA: 404 embedded directories (was a directory listing), 404 bare /api,
add X-Content-Type-Options: nosniff, cache index.html bytes once at
startup, single fs.Stat existence check, shared writeAPIError helper.
- Move gin.SetMode out of package init() into New().
Config: validate PANSY_PORT range (fall back to 8080 with a warning).
Web:
- api.ts: serialize the request body before the fetch try so a
JSON.stringify failure isn't misreported as a network error.
- AppShell: move state-specific/conflicting utilities into
active/inactiveProps so concatenated Tailwind classes don't collide.
Tests: added store DSN-pragma/memory-detection cases and SPA
directory-404 case. go build/vet/test clean (CGO off); web tsc + build
clean; re-verified in a browser (SPA, deep links, /assets 404, nav).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Implements the Pansy v1 scaffold (epic #20, phase 0).
#1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config,
gin server with slog + recovery + /api/v1/healthz, modernc SQLite store
with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration
runner, full initial schema (users, sessions, gardens, garden_shares,
garden_objects, plants, plantings), domain structs + sentinel errors,
graceful shutdown.
#2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack
Router/Query, typed /api/v1 fetch wrapper (ApiError carries status +
body so later issues can read 409 conflict rows), dev proxy /api -> :8080,
responsive app shell with stub pages for all five routes.
#3 Single binary: //go:embed of the web build with a committed placeholder,
SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched
/api and missing assets), Makefile (web/build/dev/test), README quickstart
+ env var table.
Verified: go build/vet/test clean (CGO off); binary migrates idempotently and
serves healthz; web tsc + build clean; integrated binary serves the SPA, deep
links, and correct cache headers; app mounts and navigates all five routes at
desktop and 375px widths.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi