From 52b2c09a9e013527985970742ff61cafc74c62b7 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 22 Aug 2026 19:12:29 -0400 Subject: [PATCH 1/3] Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend is rebuilt screen by screen from the handoff: warm cream ground, terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same React/Vite/TanStack stack and the same lib/ data layer; the presentation is new. - Tokens: web/src/styles/index.css declares the handoff's styles.css variables through Tailwind's @theme under the same names; dark mode is those variables overridden on by the handoff's pansy-theme.js, inlined in index.html so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit (Button, Dialog, Field, Seg, Toggle, Tag, toast). - Login / Register: the centered column over soft accent circles; OIDC button and signup footer still follow /auth/providers. - Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots from /full), a `plan` tag for "" copies, shares line, Open + share/copy/edit/delete; New garden / Share / Plan-a-season dialogs. - Plants: monogram markers derived from the name (collision-resolved across the catalog — replaces emoji icons), category chips, expandable lot cards, the scan-packet flow as a two-step dialog that never auto-creates. - Settings: Appearance (theme seg), Who gets in (read-only sign-in config), Garden assistant (self-saving toggle + chat/vision model fields), You. - Editor: a new canvas with the prototype's pointer model (wheel-to-cursor, pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom monograms/labels), plus corner resize handles; desktop three-card workspace (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px of container width, the phone chrome (header, peek panel, tool strip, mode bar). Seasons as a segmented control over the years with data plus plan copies; Undo re-reads history before reverting the newest step. - Public read-only view and the register page restyled to match. - GET /settings gains a read-only `auth` view (registration mode, local auth, OIDC issuer) so the Settings page can show what's in force. - README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 36 + DESIGN.md | 28 +- README.md | 10 + internal/api/settings.go | 28 + internal/api/settings_test.go | 19 + web/index.html | 55 +- web/package-lock.json | 19 - web/package.json | 1 - web/src/components/NotFound.tsx | 22 +- web/src/components/RouteError.tsx | 10 +- web/src/components/auth/AuthCard.tsx | 22 - web/src/components/auth/AuthScreen.tsx | 47 + web/src/components/gardens/CopyDialog.tsx | 62 + .../components/gardens/CopyGardenModal.tsx | 64 - .../components/gardens/DeleteGardenModal.tsx | 22 - web/src/components/gardens/GardenCard.tsx | 118 +- web/src/components/gardens/GardenDialog.tsx | 204 +++ .../components/gardens/GardenFormModal.tsx | 242 --- web/src/components/gardens/GardenThumb.tsx | 79 + .../components/gardens/LeaveGardenModal.tsx | 33 - web/src/components/gardens/ShareDialog.tsx | 180 ++ .../components/gardens/ShareGardenModal.tsx | 227 --- web/src/components/layout/AccountMenu.tsx | 67 + web/src/components/layout/AppShell.tsx | 238 +-- web/src/components/layout/Nav.tsx | 61 + web/src/components/layout/ThemeButton.tsx | 20 + web/src/components/plants/CategoryChips.tsx | 37 - web/src/components/plants/ColorSwatches.tsx | 50 + .../components/plants/DeletePlantModal.tsx | 26 - .../components/plants/DeleteSeedLotModal.tsx | 30 - web/src/components/plants/Monogram.tsx | 36 + web/src/components/plants/PlantCard.tsx | 201 ++- web/src/components/plants/PlantChip.tsx | 41 - .../{PlantFormModal.tsx => PlantDialog.tsx} | 167 +- web/src/components/plants/PlantIcon.tsx | 18 - .../components/plants/ScanPacketDialog.tsx | 292 ++++ web/src/components/plants/ScanPacketModal.tsx | 432 ----- web/src/components/plants/SeedLotDialog.tsx | 140 ++ web/src/components/plants/SeedLotList.tsx | 161 -- web/src/components/plants/SeedLotModal.tsx | 248 --- web/src/components/plants/SourceLink.tsx | 27 - web/src/components/ui/Alert.tsx | 12 +- web/src/components/ui/Button.tsx | 83 +- web/src/components/ui/ConfirmDialog.tsx | 63 + web/src/components/ui/ConfirmModal.tsx | 79 - web/src/components/ui/Dialog.tsx | 107 ++ web/src/components/ui/Divider.tsx | 12 - web/src/components/ui/Field.tsx | 102 ++ web/src/components/ui/Icon.tsx | 99 ++ web/src/components/ui/Modal.tsx | 110 -- web/src/components/ui/Seg.tsx | 49 + web/src/components/ui/Select.tsx | 29 - web/src/components/ui/Tag.tsx | 8 + web/src/components/ui/TextArea.tsx | 22 - web/src/components/ui/TextField.tsx | 36 - web/src/components/ui/Toggle.tsx | 36 + web/src/components/ui/cardActions.ts | 17 - web/src/components/ui/field.ts | 18 - web/src/components/ui/toast.tsx | 27 +- web/src/editor/AssistantTab.tsx | 191 +++ web/src/editor/Canvas.tsx | 762 +++++++++ web/src/editor/ChatPanel.tsx | 278 --- web/src/editor/ClearBedDialog.tsx | 35 + web/src/editor/ClearBedModal.tsx | 37 - web/src/editor/EditorHint.tsx | 18 - web/src/editor/EditorRail.tsx | 105 -- web/src/editor/GardenCanvas.tsx | 340 ---- web/src/editor/HistoryPanel.tsx | 146 -- web/src/editor/HistoryTab.tsx | 69 + web/src/editor/Inspector.tsx | 634 ++++--- web/src/editor/JournalPanel.tsx | 389 ----- web/src/editor/JournalTab.tsx | 251 +++ web/src/editor/KindSwatch.tsx | 25 + web/src/editor/MarkdownMessage.tsx | 60 +- web/src/editor/ObjectShape.tsx | 147 -- web/src/editor/Palette.tsx | 49 - web/src/editor/PlantPicker.tsx | 233 --- web/src/editor/PlopInspector.tsx | 202 --- web/src/editor/PlopLayer.tsx | 93 - web/src/editor/PlopMarker.tsx | 92 - web/src/editor/PlopOverlay.tsx | 168 -- web/src/editor/RecentPlants.tsx | 29 - web/src/editor/SeasonPicker.tsx | 69 - web/src/editor/SeedTray.tsx | 57 - web/src/editor/SelectionOverlay.tsx | 216 --- web/src/editor/Toolkit.tsx | 215 +++ web/src/editor/UndoButton.tsx | 55 - web/src/editor/arming.ts | 57 + web/src/editor/kinds.ts | 66 +- web/src/editor/seasons.ts | 73 + web/src/editor/shared.ts | 140 +- web/src/editor/store.ts | 228 +-- web/src/editor/useUndoLast.ts | 39 + web/src/editor/useViewport.ts | 103 -- web/src/lib/monogram.test.ts | 67 + web/src/lib/monogram.ts | 63 + web/src/lib/plan.test.ts | 37 + web/src/lib/plan.ts | 42 + web/src/lib/seedTray.ts | 84 - web/src/lib/settings.ts | 34 +- web/src/lib/theme.ts | 103 ++ web/src/lib/units.test.ts | 33 + web/src/lib/units.ts | 25 + web/src/pages/GardenEditorPage.tsx | 1495 +++++++---------- web/src/pages/GardensPage.tsx | 104 +- web/src/pages/LoginPage.tsx | 66 +- web/src/pages/PlantsPage.tsx | 215 ++- web/src/pages/PublicGardenPage.tsx | 94 +- web/src/pages/RegisterPage.tsx | 164 +- web/src/pages/SettingsPage.tsx | 336 ++-- web/src/styles/index.css | 474 +++++- 111 files changed, 6621 insertions(+), 7215 deletions(-) delete mode 100644 web/src/components/auth/AuthCard.tsx create mode 100644 web/src/components/auth/AuthScreen.tsx create mode 100644 web/src/components/gardens/CopyDialog.tsx delete mode 100644 web/src/components/gardens/CopyGardenModal.tsx delete mode 100644 web/src/components/gardens/DeleteGardenModal.tsx create mode 100644 web/src/components/gardens/GardenDialog.tsx delete mode 100644 web/src/components/gardens/GardenFormModal.tsx create mode 100644 web/src/components/gardens/GardenThumb.tsx delete mode 100644 web/src/components/gardens/LeaveGardenModal.tsx create mode 100644 web/src/components/gardens/ShareDialog.tsx delete mode 100644 web/src/components/gardens/ShareGardenModal.tsx create mode 100644 web/src/components/layout/AccountMenu.tsx create mode 100644 web/src/components/layout/Nav.tsx create mode 100644 web/src/components/layout/ThemeButton.tsx delete mode 100644 web/src/components/plants/CategoryChips.tsx create mode 100644 web/src/components/plants/ColorSwatches.tsx delete mode 100644 web/src/components/plants/DeletePlantModal.tsx delete mode 100644 web/src/components/plants/DeleteSeedLotModal.tsx create mode 100644 web/src/components/plants/Monogram.tsx delete mode 100644 web/src/components/plants/PlantChip.tsx rename web/src/components/plants/{PlantFormModal.tsx => PlantDialog.tsx} (51%) delete mode 100644 web/src/components/plants/PlantIcon.tsx create mode 100644 web/src/components/plants/ScanPacketDialog.tsx delete mode 100644 web/src/components/plants/ScanPacketModal.tsx create mode 100644 web/src/components/plants/SeedLotDialog.tsx delete mode 100644 web/src/components/plants/SeedLotList.tsx delete mode 100644 web/src/components/plants/SeedLotModal.tsx delete mode 100644 web/src/components/plants/SourceLink.tsx create mode 100644 web/src/components/ui/ConfirmDialog.tsx delete mode 100644 web/src/components/ui/ConfirmModal.tsx create mode 100644 web/src/components/ui/Dialog.tsx delete mode 100644 web/src/components/ui/Divider.tsx create mode 100644 web/src/components/ui/Field.tsx create mode 100644 web/src/components/ui/Icon.tsx delete mode 100644 web/src/components/ui/Modal.tsx create mode 100644 web/src/components/ui/Seg.tsx delete mode 100644 web/src/components/ui/Select.tsx create mode 100644 web/src/components/ui/Tag.tsx delete mode 100644 web/src/components/ui/TextArea.tsx delete mode 100644 web/src/components/ui/TextField.tsx create mode 100644 web/src/components/ui/Toggle.tsx delete mode 100644 web/src/components/ui/cardActions.ts delete mode 100644 web/src/components/ui/field.ts create mode 100644 web/src/editor/AssistantTab.tsx create mode 100644 web/src/editor/Canvas.tsx delete mode 100644 web/src/editor/ChatPanel.tsx create mode 100644 web/src/editor/ClearBedDialog.tsx delete mode 100644 web/src/editor/ClearBedModal.tsx delete mode 100644 web/src/editor/EditorHint.tsx delete mode 100644 web/src/editor/EditorRail.tsx delete mode 100644 web/src/editor/GardenCanvas.tsx delete mode 100644 web/src/editor/HistoryPanel.tsx create mode 100644 web/src/editor/HistoryTab.tsx delete mode 100644 web/src/editor/JournalPanel.tsx create mode 100644 web/src/editor/JournalTab.tsx create mode 100644 web/src/editor/KindSwatch.tsx delete mode 100644 web/src/editor/ObjectShape.tsx delete mode 100644 web/src/editor/Palette.tsx delete mode 100644 web/src/editor/PlantPicker.tsx delete mode 100644 web/src/editor/PlopInspector.tsx delete mode 100644 web/src/editor/PlopLayer.tsx delete mode 100644 web/src/editor/PlopMarker.tsx delete mode 100644 web/src/editor/PlopOverlay.tsx delete mode 100644 web/src/editor/RecentPlants.tsx delete mode 100644 web/src/editor/SeasonPicker.tsx delete mode 100644 web/src/editor/SeedTray.tsx delete mode 100644 web/src/editor/SelectionOverlay.tsx create mode 100644 web/src/editor/Toolkit.tsx delete mode 100644 web/src/editor/UndoButton.tsx create mode 100644 web/src/editor/arming.ts create mode 100644 web/src/editor/seasons.ts create mode 100644 web/src/editor/useUndoLast.ts delete mode 100644 web/src/editor/useViewport.ts create mode 100644 web/src/lib/monogram.test.ts create mode 100644 web/src/lib/monogram.ts create mode 100644 web/src/lib/plan.test.ts create mode 100644 web/src/lib/plan.ts delete mode 100644 web/src/lib/seedTray.ts create mode 100644 web/src/lib/theme.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0b59a49..1d9b23a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,42 @@ 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 + `` 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 diff --git a/DESIGN.md b/DESIGN.md index 3d975c7..a3e7ac4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -43,13 +43,10 @@ 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. -- **One gesture library: `@use-gesture/react`** for unified drag / wheel-zoom / pinch on desktop + touch. Everything else is hand-rolled pointer math. +- **Hand-rolled pointer events, no gesture library.** One pointer pans or drags, two pinch about their centroid, the wheel zooms to the cursor; a click is a drag that never crossed its threshold (3px mouse, 7px touch). Ported from the design prototype (`web/src/editor/Canvas.tsx`), which proved the model on both inputs. - Viewport = a single ``; state `{tx, ty, scale}` where scale = px per cm. Wheel/pinch zooms to cursor; drag on empty space pans; drag on an element moves it. - **Field view and bed interior are the same canvas.** "Click into a bed" animates the viewport to fit the object and sets `focusedObjectId` (mirrored to `?focus=` URL param for deep links). Focused mode dims siblings and enables plop placement; Escape/tap-out zooms back. -- **Semantic zoom**, 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. +- **Semantic zoom**, by on-screen size rather than a fixed scale: a plop is always a circle in its plant's color; its monogram appears once its radius is ≥ 9px on screen, its plant name below it at ≥ 34px; an object's name appears when its longer side is > 54px. Zoomed right out the beds still read by their plops' colors — "what's planted where" at a glance. ## API @@ -78,7 +75,8 @@ GET /gardens/:id/journal/counts ← entries per object, for the "has notes" in 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 +GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model; + plus a read-only `auth` view (registration mode, local auth, OIDC issuer) GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email) GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability @@ -125,13 +123,17 @@ 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. +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. -- **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, and the mobile `mode`. -- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (shown once a bed is focused; focusing a bed puts you in this mode — a "Recent" strip of what you've most recently planted *in this garden* (#100, derived from plantings, not the manual tray) for one-tap re-arming, the seed tray, the picker, and a clump/rows **fill** control that runs the region fill (#77) the UI couldn't reach before), **Journal**, **Assistant** (Assistant hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. The mode bar is **always visible**, and the rail (inspector, journal, history, assistant) is an **in-flow peek** (#101): a ≤50vh panel the editor's flex column places BETWEEN the canvas and the mode bar, so the canvas flexes to keep the garden visible above it and the mode bar reachable below — selecting a bed no longer hides the whole garden, and you can switch modes without closing a panel. Desktop keeps its side-column layout (the mode bar is `md:hidden`, the rail is the right column) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode. -- **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. +- **Tokens, once.** `web/src/styles/index.css` declares the handoff's `styles.css` variables (colors, fonts, radii, the `--p-*` canvas/ink tokens) through Tailwind's `@theme`, so utilities reference them by name — and dark mode is nothing but those same variables overridden on `` by the bootstrap inlined in `web/index.html` (a port of the handoff's `pansy-theme.js`, run before first paint so a dark-mode user never sees a cream flash). No second stylesheet, no class swapping; `web/src/lib/theme.ts` is the typed React face of it. +- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog), `/settings` (admin), `/g/:token` (public read-only). Auth guard on the router root via `/auth/me`. Every page renders its own nav; the root shell is only a Suspense boundary plus the toast stack. +- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One Zustand store (`web/src/editor/store.ts`) for ephemeral editor state only: camera `{tx, ty, s}`, selection, focused bed, the armed kind or plant (+ seed lot), rail tab, phone mode, journal scope, in-flight drag geometry. +- **The canvas (`web/src/editor/Canvas.tsx`)** is one SVG with one `translate/scale` group and its own pointer-event model (above). Object drags snap their center to a 3″ grid (the garden's own grid when it snaps) and commit ONE PATCH on release; plops drag in their bed's local frame and may overhang its edge by half their radius (the spacing rule). Fit and focus animate through a CSS transition on the group; drags and zooms don't. Corner handles on the selected object resize it — the one addition to the prototype, since the kinds' fixed default sizes can't make a 2′×8′ bed. Tap-to-place makes a one-plant plop (radius = spacing/2); "Fill the bed" (rows or clumps, `POST /objects/:id/fill`) is how you plant in bulk. +- **Two chromes, one tree.** The editor measures its own container: ≥ 760px is the desktop workspace (toolkit 216px | plan | rail 336px, the rail's Plot/Journal/History/Assistant tabs); below it the phone layout — header, full-screen canvas, an in-flow **peek** (≤ 45% tall, docked between the canvas and the mode bar) for the inspector, journal or assistant, a tool strip for Build or Plants mode, and the always-visible mode bar. Focusing a bed on the phone switches to Plants mode; a plant can also be tapped straight into any bed without focusing. +- **Plant markers** are the plant's color plus a monogram derived from its name (`web/src/lib/monogram.ts`, collisions resolved across the whole catalog so a letter means the same thing on every screen). +- **Undo** in the editor's header reverts the newest change set still in effect (not already reverted, not itself a revert) — after re-reading the history, because the cached list trails the canvas right after a placement, and undoing the step *before* the one you meant is the worst thing an undo button can do. The History tab offers every step, including undoing an undo. +- **Seasons** are the years with planting data (`GET /gardens/:id/years`); the current year is live, any other is read-only. A *plan* is a whole-garden copy named `` (`web/src/lib/plan.ts`); the season control lists a garden's plan copies and, from inside one, the way back. The name is the only link the API keeps, which is deliberate — rename the copy and it is simply a garden. +- Pure helpers stay in `web/src/lib/` (geometry, units including the compact `3′` / `1′4″` display, monograms, plan names), unit-tested. ## Roadmap @@ -151,6 +153,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. Emoji plant icons (zero assets); SVG icon set later if wanted. +4. Plant markers are the plant's color plus a 1–2 letter monogram derived from its name (`web/src/lib/monogram.ts`) — still zero assets. The `icon` (emoji) column stays in the API for compatibility; the UI no longer shows it. 5. No background/satellite image tracing (cheap to add later as a garden background field). 6. 409-and-refetch conflict handling; no real-time sync. diff --git a/README.md b/README.md index 9d6f4ea..05a1788 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,16 @@ Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, 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 `` 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 ``; 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: diff --git a/internal/api/settings.go b/internal/api/settings.go index a2cc6eb..0b9a9ce 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -39,6 +39,27 @@ type settingsResponse struct { // 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 { @@ -79,6 +100,13 @@ func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) 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 } diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index a7fd501..4dc304a 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -81,6 +81,25 @@ func TestSettingsInheritFromEnv(t *testing.T) { 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 diff --git a/web/index.html b/web/index.html index edcae38..22e46c5 100644 --- a/web/index.html +++ b/web/index.html @@ -4,9 +4,62 @@ - + pansy + + + +
diff --git a/web/package-lock.json b/web/package-lock.json index 4506010..48ef8c9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,7 +10,6 @@ "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", @@ -1757,24 +1756,6 @@ "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "license": "ISC" }, - "node_modules/@use-gesture/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", - "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", - "license": "MIT" - }, - "node_modules/@use-gesture/react": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", - "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", - "license": "MIT", - "dependencies": { - "@use-gesture/core": "10.3.1" - }, - "peerDependencies": { - "react": ">= 16.8.0" - } - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", diff --git a/web/package.json b/web/package.json index 9748e99..ef37ab4 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,6 @@ "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", diff --git a/web/src/components/NotFound.tsx b/web/src/components/NotFound.tsx index 11cf65a..459f785 100644 --- a/web/src/components/NotFound.tsx +++ b/web/src/components/NotFound.tsx @@ -1,20 +1,22 @@ import { Link } from '@tanstack/react-router' -import { buttonClasses } from '@/components/ui/Button' +import { Nav } from '@/components/layout/Nav' +import { Icon } from '@/components/ui/Icon' import { usePageTitle } from '@/lib/usePageTitle' /** The router's catch-all for unknown paths. */ export function NotFound() { usePageTitle('Not found') return ( -
-

- 🌱 -

-

Page not found

-

That page doesn't exist — the link may be wrong or the page moved.

- - Back to gardens - +
+
) } diff --git a/web/src/components/RouteError.tsx b/web/src/components/RouteError.tsx index 095900e..0b2ee2a 100644 --- a/web/src/components/RouteError.tsx +++ b/web/src/components/RouteError.tsx @@ -8,10 +8,12 @@ import { errorMessage } from '@/lib/api' export function RouteError({ error }: { error: Error }) { const router = useRouter() return ( -
-

Something went wrong

-

{errorMessage(error, 'An unexpected error occurred.')}

- +
+

Something went wrong

+

{errorMessage(error, 'An unexpected error occurred.')}

+
) } diff --git a/web/src/components/auth/AuthCard.tsx b/web/src/components/auth/AuthCard.tsx deleted file mode 100644 index efe04a1..0000000 --- a/web/src/components/auth/AuthCard.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 ( -
-
-

{title}

- {subtitle &&

{subtitle}

} -
{children}
-
-
- ) -} diff --git a/web/src/components/auth/AuthScreen.tsx b/web/src/components/auth/AuthScreen.tsx new file mode 100644 index 0000000..91da0a6 --- /dev/null +++ b/web/src/components/auth/AuthScreen.tsx @@ -0,0 +1,47 @@ +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 ( +
+
+
+ +
+
+ +

pansy

+

Plan the plot. Keep the notes. Grow the thing.

+
+ {children} +
+
+ ) +} + +/** The card the forms sit in. */ +export function AuthCard({ children }: { children: ReactNode }) { + return
{children}
+} + +/** "— or —" between the two sign-in methods. */ +export function OrDivider() { + return ( +
+ + or + +
+ ) +} diff --git a/web/src/components/gardens/CopyDialog.tsx b/web/src/components/gardens/CopyDialog.tsx new file mode 100644 index 0000000..04fbd57 --- /dev/null +++ b/web/src/components/gardens/CopyDialog.tsx @@ -0,0 +1,62 @@ +import { 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, type Garden } from '@/lib/gardens' +import { 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 "", 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 base = parsePlanName(garden.name)?.base ?? garden.name + const year = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1 + const [name, setName] = useState(() => planNameFor(base, year)) + const [error, setError] = useState(null) + + 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 ( + +
+

+ A copy of {garden.name} to scheme in — rearrange freely, the + original stays put. Beds and what's planted come along; shares and the public link don't. +

+ setName(e.target.value)} /> +

Keep the “— {year}” and it shows up as that season's plan in the editor.

+ {error && {error}} +
+ + +
+ +
+ ) +} diff --git a/web/src/components/gardens/CopyGardenModal.tsx b/web/src/components/gardens/CopyGardenModal.tsx deleted file mode 100644 index 0e0637c..0000000 --- a/web/src/components/gardens/CopyGardenModal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -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(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 ( - -
-

- Make a copy of {garden.name} 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. -

- setName(e.target.value)} - /> - {error && {error}} -
- - -
- -
- ) -} diff --git a/web/src/components/gardens/DeleteGardenModal.tsx b/web/src/components/gardens/DeleteGardenModal.tsx deleted file mode 100644 index c187c89..0000000 --- a/web/src/components/gardens/DeleteGardenModal.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ConfirmModal } from '@/components/ui/ConfirmModal' -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() - return ( - deletion.mutateAsync(garden.id)} - onClose={onClose} - > -

- Delete {garden.name} and everything planned in it? - This can't be undone. -

-
- ) -} diff --git a/web/src/components/gardens/GardenCard.tsx b/web/src/components/gardens/GardenCard.tsx index d40270e..93dc18d 100644 --- a/web/src/components/gardens/GardenCard.tsx +++ b/web/src/components/gardens/GardenCard.tsx @@ -1,14 +1,24 @@ +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 { formatDimensions } from '@/lib/units' -import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions' +import { useGardenFull } from '@/lib/objects' +import { 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'] /** - * 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. + * One garden as a card: the plot thumbnail (a link into the editor), name + an + * optional `plan` tag, size, a counts line, who it's shared with, and a footer + * of Open + share / copy / edit / delete. A garden shared WITH you shows its + * role and a leave action instead of the owner's tools. */ export function GardenCard({ garden, @@ -28,47 +38,75 @@ 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 planYear = planYearOf(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() + 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 ( -
+
+ + +
-

{garden.name}

- {!owner && garden.myRole && ( - - shared · {garden.myRole} - + + {garden.name} + + {planYear != null && plan} + + {formatSize(garden.widthCm, garden.heightCm, garden.unitPref)} + +
+
{meta}
+ {sharedLine &&
{sharedLine}
} +
+ + Open + + {owner ? ( + <> + + + + + + ) : ( + )}
-

- {formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)} -

- {garden.notes &&

{garden.notes}

} - -
- {owner ? ( - <> - - - - - - ) : ( - - )}
) diff --git a/web/src/components/gardens/GardenDialog.tsx b/web/src/components/gardens/GardenDialog.tsx new file mode 100644 index 0000000..00e0a3b --- /dev/null +++ b/web/src/components/gardens/GardenDialog.tsx @@ -0,0 +1,204 @@ +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, + dimensionInputMode, + dimensionUnitLabel, + formatCm, + formatDimensionInput, + isValidDimensionCm, + MIN_GARDEN_GRID_CM, + parseDimension, + 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; switching units converts what's typed so + * the physical size holds. 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(initialUnit) + const [width, setWidth] = useState(() => + garden ? formatDimensionInput(garden.widthCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_W_FT), 'imperial'), + ) + const [height, setHeight] = useState(() => + garden ? formatDimensionInput(garden.heightCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_H_FT), 'imperial'), + ) + const [gridSize, setGridSize] = useState(() => + formatDimensionInput(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(null) + const [formError, setFormError] = useState(null) + + function changeUnit(next: UnitPref) { + const convert = (s: string) => { + const cm = parseDimension(s, unit) + return cm === null ? s : formatDimensionInput(cm, next) + } + setWidth(convert(width)) + setHeight(convert(height)) + setGridSize(convert(gridSize)) + setUnit(next) + } + + const gridSizeCm = parseDimension(gridSize, unit) + 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 = 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 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 } + 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(formatDimensionInput(current.widthCm, current.unitPref)) + setHeight(formatDimensionInput(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 — 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 ( + +
+ {conflict && {conflict}} + setName(e.target.value)} /> +
+ setWidth(e.target.value)} + wrapperClassName="flex-1" + /> + setHeight(e.target.value)} + wrapperClassName="flex-1" + /> +
+ + +
+
+
Stored in centimeters under the hood — {unit === 'imperial' ? 'feet are' : 'meters are'} just how you talk.
+ + {!more ? ( + + ) : ( +
+
+ setGridSize(e.target.value)} + 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 + } + /> +
+ + +
+
+ setNotes(e.target.value)} /> +
+ )} + + {formError && {formError}} +
+ + +
+ +
+ ) +} diff --git a/web/src/components/gardens/GardenFormModal.tsx b/web/src/components/gardens/GardenFormModal.tsx deleted file mode 100644 index 496d292..0000000 --- a/web/src/components/gardens/GardenFormModal.tsx +++ /dev/null @@ -1,242 +0,0 @@ -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(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(null) - const [formError, setFormError] = useState(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 ( - -
- {conflict && {conflict}} - - setName(e.target.value)} /> - - setSnapToGrid(e.target.checked)} - className="h-4 w-4 rounded border-border" - /> - Snap objects - -
- {gridTooFine && ( -

- {formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid. Plant spacing lives on each bed - (Bed grid in the inspector), not here. -

- )} -
- -