Commit Graph
137 Commits
Author SHA1 Message Date
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 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
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
steve 90af03d597 Merge pull request 'EXIF orientation: bake it in when normalizing images (#103)' (#111) from feat/exif-orientation into main
Build image / build-and-push (push) Successful in 9s
2026-07-22 05:35:46 +00:00
steveandClaude Opus 4.8 8aad2278a0 Address EXIF review: single alloc, no per-pixel boxing, hardened parser
Build image / build-and-push (push) Successful in 8s
Gadfly on #103:

- (4 models) applyOrientation allocated a WxH RGBA then threw it away for a
  quarter-turn to allocate HxW. Compute the output dims once, allocate one
  buffer.
- (perf) The At/Set loop boxed a color.Color per pixel — millions of heap
  allocs on a full-res rotation. Convert to *image.RGBA once (draw.Draw)
  then copy 4 bytes per pixel by offset. No boxing.
- (2 findings) exifOrientation didn't skip 0xFF fill bytes before a marker,
  so a spec-valid padded APP1 would be misread. Skip them.
- (2 findings) orientationFromApp1 read the tag's inline value without
  checking its type/count — a mistyped LONG/offset would be read as a
  bogus SHORT. Require type=SHORT, count=1; also validate the TIFF 0x2A
  magic agrees with the byte order.
- Tests now cover all 8 orientations (added the mirror/transpose cases
  2/4/5/7, the most error-prone switch arms) as t.Run subtests.

All imagenorm tests green; gofmt clean; still CGO_ENABLED=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:35:19 -04:00
steve e9df1f05d8 Merge pull request 'Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99)' (#110) from feat/editor-modes into main
Build image / build-and-push (push) Successful in 20s
2026-07-22 05:32:47 +00:00
steveandClaude Opus 4.8 2d25b7e28e Address editor-mode review: mode↔focus↔rail syncing
Build image / build-and-push (push) Successful in 11s
Gadfly on #99, the real ones — all in the mode/focus/rail interplay:

- (3 models) Closing a journal/assistant rail while a bed was focused
  hard-set mode='fixtures', docking the OBJECT palette inside the focused
  bed with the seed tray unreachable. Derive it: closing a panel returns
  to Plants if still focused, else Fixtures. The canvas-mode effect now
  also follows UN-focus (plants→fixtures) and won't override a panel mode.
- (2 models) Plants mode on a focused non-plantable object (reachable via
  a ?focus= deep link) showed the misleading "tap a bed" hint and no way
  out on mobile. Now it says what's wrong and offers Done (exit focus).
- Selecting an object leaves a panel mode, so closing the inspector can't
  strand the bar on Journal/Assistant with nothing open.
- Tapping Fixtures steps out of a focused bed (you're arranging again).
- Viewer mode bar drops Fixtures/Plants (a viewer can't place anything),
  leaving Journal + Assistant.
- Safety: if the assistant capability flips off live while it's the active
  mode, fall back to a canvas mode and close the orphaned chat rail.

Maintainability: extracted the shared seed-tray + Done + Clear cluster into
PlantPlacementTools (was duplicated between the desktop focus toolbar and
the mobile Plants strip); DEFAULT_MODE const replaces the twice-hardcoded
'fixtures'; selectMode reads the reactive railTab, not getState().

Verified live at 390px: focus a bed → Plants; open journal → close → back
to Plants (tray), not the Fixtures palette; tap Fixtures → exits focus.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:31:03 -04:00
steveandClaude Opus 4.8 0ab90a01cb EXIF orientation: bake it in when normalizing images (#103)
Build image / build-and-push (push) Successful in 8s
Gadfly review (reusable) / review (pull_request) Successful in 7m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m8s
imagenorm decoded and re-encoded to JPEG but ignored the EXIF Orientation
tag. Phone cameras store the sensor pixels one way and set an EXIF flag to
rotate on display, so a "portrait" JPEG is really a landscape bitmap tagged
"rotate 90°" — and our re-encode strips EXIF, so without baking the rotation
in, a packet photographed in portrait reaches the vision model sideways
(bad OCR) and any future thumbnail is wrong.

- exifOrientation: a small pure-Go parser that walks the JPEG APP1/Exif
  segment for tag 0x0112, returning 1 (normal) for non-JPEG or unparseable
  input — never guess a rotation onto a correct image. No cgo, no new dep.
- applyOrientation: bakes in all 8 orientations (the 4 rotations + mirrors)
  after downscale (cheaper to rotate the small image; a 90° turn swaps the
  sides but not the longest edge, so the downscale bound still holds).
- Non-JPEG paths (HEIC/webp/png) are untouched — their decoders own
  orientation and they carry no JPEG EXIF.

Tests build oriented JPEGs (a corner marker + a spliced Exif APP1) and
assert the marker lands where each orientation says, dims swapping for the
quarter-turns; plus parser defaults for non-JPEG / no-EXIF / garbage.
Stays CGO_ENABLED=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:23:08 -04:00
steveandClaude Opus 4.8 9ec626302b Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 8m55s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m56s
On a phone the editor stacked a control column (title, share, season, a
7-chip palette wrapping to 3 rows, journal/history/assistant) ABOVE the
canvas, crushing the garden — the point of the app — into a short strip.
There was no single "mode" switch: fixtures lived in the palette, plants
in a floating focus toolbar, and journal/assistant in the rail.

Mobile-first now (#99): the canvas is the whole screen, and a bottom mode
bar switches the tools docked beneath it —
- Fixtures  → the object palette (arm → tap to place)
- Plants    → the seed tray, once a bed is focused; focusing a bed enters
              this mode. No bed focused → a "tap a bed, then Plant here"
              hint. Done planting / Clear ride along.
- Journal   → opens the journal rail sheet
- Assistant → opens the chat rail sheet (hidden with no model configured)

A slim mobile top strip keeps the garden name / season / share that lived
in the desktop column. Closing a panel rail returns to a canvas mode so
the bar reappears; History stays a rail sub-tab (not a fifth mode).

Desktop is untouched: the mode bar is md:hidden and the side-column layout
stands; `mode` is an inert hint there. Store gains `mode`/`setMode`
(reset with the rest of transient state on garden switch).

Verified live at 390px (each mode + the select-bed → Plant here → seed-tray
flow, canvas now dominant) and 1280px (desktop unchanged). tsc + vitest
(92) + build green. DESIGN.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:16:44 -04:00
steve 8a27df9e9c Merge pull request 'Mobile-first app shell: bottom tab nav + slim top bar (#98)' (#109) from feat/mobile-shell into main
Build image / build-and-push (push) Successful in 18s
2026-07-22 05:03:54 +00:00
steveandClaude Opus 4.8 79f03acea8 Address mobile-shell review: logout retry, public-garden bar, class hygiene
Build image / build-and-push (push) Successful in 19s
Gadfly findings on #98:

- Logout failure (4 models): the catch called setOpen(false), closing the
  popover and hiding the only "Retry sign out" affordance — contradicting
  its own comment. Keep the popover open on failure so the retry button
  (driven by logout.isError) stays on screen.
- Public garden (2 models): the bottom-bar suppression missed /g/$token,
  whose PublicGardenPage also renders a 100dvh-8rem canvas — a signed-in
  viewer of a shared link got the bar overlapping it. Suppress there too.
- BottomNav base className carried text-muted, fighting the active
  text-accent-strong and violating the file's own "color only in state
  props" convention (3 models). Moved color entirely to the state props.
- BottomNav sections prop type was a hand-written partial copy of the
  sections shape; derive `Section = (typeof sections)[number]` (2 models).
- Popover open state now resets on route change, so navigating (bottom nav
  or browser back) can't strand an invisible full-screen backdrop.
- Coupled the <main> bottom clearance to BottomNav's height (both 3.5rem /
  h-14, co-located with a note) and switched the template-string className
  to cn().

Verified live: route-change closes the popover with no lingering backdrop.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:02:06 -04:00
steve 1b11b2bd62 Merge pull request 'Resume the last garden on this device (#97)' (#108) from feat/resume-last-garden into main
Build image / build-and-push (push) Successful in 7s
2026-07-22 04:54:33 +00:00
steveandClaude Opus 4.8 d8003b11fb Address review: key the "gone" message and the bounce off the same query
Build image / build-and-push (push) Successful in 12s
Gadfly (2 models, correctness): the redirect effect checked live.isError
but the rendered "taking you to your gardens…" message read full.error —
which is the SEASON query when viewing a past year. A season-view 404 with
a healthy live garden would then show a redirect promise the effect never
fires, stranding the user.

Derive gardenGone once from the LIVE query (garden existence doesn't depend
on the season viewed) and use it for BOTH the bounce effect and the render
message, so the promise and the redirect can't disagree. Also removes the
duplicated not-found reasoning the maintainability finding flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:53:43 -04:00
steveandClaude Opus 4.8 9f434a801a Mobile-first app shell: bottom tab nav + slim top bar (#98)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 10m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m33s
The top bar was desktop-shaped — logo + Gardens/Plants/Settings + name +
Sign out crammed in one row, wrapping "Sign out" to two lines at 390px,
with no thumb-reachable navigation.

Mobile-first now:
- Slim top bar: brand (left, still the way back to /gardens) + a compact
  account control (right).
- Section nav (Gardens/Plants/Settings) moves to a bottom tab bar in the
  thumb zone, safe-area-aware, ≥52px targets, shown only when signed in.
- Account/sign-out is a small top-right popover (Signed in as … / Sign
  out), reachable in 2 taps. Close-on-outside-tap via a backdrop button,
  no document listener.
- Desktop (md:+) keeps the inline top nav; the bottom bar is md:hidden.
- The editor is a full-screen context, so it owns the bottom of the
  screen — the app bottom bar hides on /gardens/$id (via useMatchRoute),
  leaving no competing bars there; the editor's own mode bar arrives in
  the mode-model issue.

Verified live at 390px and 1280px: bottom bar on the list, hidden in the
editor, account menu opens, desktop unchanged. 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 00:51:23 -04:00
steveandClaude Opus 4.8 14af9502d4 Resume the last garden on this device (#97)
Build image / build-and-push (push) Successful in 17s
Gadfly review (reusable) / review (pull_request) Successful in 9m34s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m34s
`/` always dumped you on the gardens list; a phone user who lives in one
garden had to open the list and tap in every time. Now the device
remembers the garden it was last in and `/` resumes there.

- lib/lastGarden.ts: per-device localStorage (pansy:last-garden), same
  swallow-failures rationale as the seed tray / recents. getLastGardenId
  guards against a non-positive/garbage stored value.
- The `/` route redirects to the stored garden when present, else /gardens.
- The editor records the garden on successful load, and — if it 404s
  (deleted or access revoked) — forgets it (only if it's the stored one,
  so a bad direct link can't wipe a good resume target) and bounces to the
  list, so a stale id can't trap the user on an error screen. Transient
  errors still show the retryable message.
- ApiError.isNotFound getter (mirrors isConflict/isUnauthorized).

Verified live at 390px: resume into the last garden; a stale id bounces to
/gardens and clears itself. tsc + vitest (incl. new lastGarden test) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:44:58 -04:00
steveandClaude Opus 4.8 283010dccb docs: fix DESIGN fill-mode bullet mangled by the #94/#95 merge
Build image / build-and-push (push) Successful in 13s
The two PRs' DESIGN edits auto-merged into a self-contradictory run-on
("only the radius->spacing relationship differs" next to "BOTH the plop
radius AND the edge inset differ"). Drop the stale clause; the edgeInset
sentence is the accurate one.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:23:46 -04:00
steve 3685308d10 Merge pull request 'Fill mode: a "grid" layout that plants individual plants in rows' (#95) from feat/fill-mode into main
Build image / build-and-push (push) Successful in 6s
2026-07-22 04:22:46 +00:00
steve afa9288b4b Merge pull request 'Seed-packet capture: vision model, extraction, catalog match, create (backend)' (#94) from feat/seed-packet-backend into main
Build image / build-and-push (push) Successful in 7s
2026-07-22 04:22:27 +00:00
steveandClaude Opus 4.8 c80cf15bf1 Address fill-mode review: grid edge inset + drop dead coverage append
Build image / build-and-push (push) Successful in 18s
Gadfly findings on #95:

- Correctness (3 models): the shared inset formula radius-spacing/2
  collapses to 0 in grid mode (radius = spacing/2), so grid's outer row
  planted flush on / overhanging the bed edge instead of the half-spacing
  in that the rule wants. The inset genuinely differs by layout — a grid
  plant sits AT the plop centre (inset spacing/2), a clump's plants reach
  its rim (inset radius-spacing/2, overhanging by a half). Split it into a
  new edgeInset(radius, spacing, layout); hexCenters now takes a
  precomputed inset and is pure geometry (no spacing/layout knowledge).
  Regression guard: grid plants land at ±25 on a 60cm bed, not ±30.

- Performance (2 findings): the in-loop `existing = append(existing, *p)`
  was dead — every plop in one fill shares a radius and sits on a distinct
  lattice point, and a plop is "covered" only when wholly inside another,
  impossible between equal-radius circles at different centres. Removing it
  stops the coveredByExisting scan growing during the fill (an empty-bed
  grid fill's check was needlessly quadratic in the plop count).

- Docs: FillRegion/fillLoaded/hexCenters comments and the DESIGN.md bullets
  updated for plopRadiusFor/edgeInset (were still citing defaultPlopRadius
  and "written out in hexCenters").

- Test hygiene: split the grid + bad-layout cases out of TestFillAndClearAPI
  into TestFillLayoutAPI (one concern per test).

The enum-tag finding is a non-issue: majordomo's DefineTool derives its arg
schema from the same struct-tag reflection as Generate (proven by the vision
SeedPacket enum), and the service validates mode regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:21:47 -04:00
steveandClaude Opus 4.8 6a4fd40bc3 Address seed-packet review: 413 mapping, deadline, rollback, dedup
Build image / build-and-push (push) Successful in 6s
Gadfly findings on #94, the real ones:

- scanSeedPacket extends only the READ deadline; a slow upload + a live
  vision call runs past the server's absolute 30s WriteTimeout and the
  successful response is silently dropped (the #78 failure mode). Extend
  the write deadline too (scanWriteTimeout).
- An oversized upload tripping MaxBytesReader was mapped to 400; it's 413.
  Detect *http.MaxBytesError and report IMAGE_TOO_LARGE.
- Split imagenorm error mapping: ErrTooLarge->413, ErrUnsupported->400,
  genuine read/encode faults (and a failed file.Open)->500, not 400.
- CreateFromPacket discarded the plant it created when the lot then
  failed, contradicting its own doc. Roll the new plant back instead so
  the confirm is all-or-nothing (a fresh plant has no lots/plantings, so
  the delete is safe; log-and-continue on cleanup failure).
- Dedup: packetLotRequest and seedLotCreateRequest shared every lot
  field. Extract a seedLotFields base both use. validCategory now reuses
  plantCategories. EffectiveConfig resolves agent+vision from one
  settings-row read instead of two.
- capabilities swallowed an EffectiveVision error silently; log it.
- vision test hand-copied Extract's body (drift risk). Split generate()
  out of Extract so the hermetic test drives the real request builder.

Tests: rollback-on-lot-failure (service), oversized->413 (api).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:13:42 -04:00
steveandClaude Opus 4.8 7c1faa1515 Fill mode: a "grid" layout that plants individual plants in rows (#77)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 12m51s
Adversarial Review (Gadfly) / review (pull_request) Successful in 12m51s
Steve chose option 3: keep clumps as the default primitive, add a grid/rows
fill mode, so sketching and planning are different operations with different
outputs rather than one model forced to be both.

A plop is a CLUMP, not a plant — great for "a few plops of garlic in a corner",
useless for drawing a plantable 8-rows-of-garlic bed (that came out as ~15
blobs, #77). FillLayout selects what a fill packs:
- clump (default, unchanged): radius 1.5×spacing, ~7 plants per plop.
- grid: radius spacing/2, pitch = spacing, ONE plant per plop — rows you could
  actually plant from.

The geometry is the SAME hexCenters lattice and the SAME #75 half-spacing edge
rule; only the radius→spacing relationship differs (plopRadiusFor). Grid keeps
no 15cm floor — its whole point is true spacing — while clump keeps it so a
tiny-spacing plant doesn't make invisible clumps.

Threaded through FillRegion/FillNamedRegion (empty layout = clump, so existing
callers are unchanged; unknown layout = ErrInvalidInput), the REST /fill
endpoint (`layout`), and the agent's fill_region tool (`mode`, enum clump|grid),
so "plant the bed in rows" works.

Tests: grid produces many more, single-plant plops than clump on the same bed
(radius spacing/2, derived count 1); unknown layout is refused at both the
service and the API. maxFillPlops still caps a grid fill of a huge bed.

No frontend fill affordance exists yet (fill is agent-only in the UI; the fill
UI was deferred in #82), so the mode toggle rides along when that's built —
noted. Docs: DESIGN placement-model decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:01:27 -04:00
steve 1e2b763566 Rough edges: mobile 100dvh, session-expiry in chat, sticky error toasts, journal date filter (#93)
Build image / build-and-push (push) Successful in 11s
Part of #85. 100vh→100dvh on both full-height pages (editor + public), a 401
during chat now redirects to /login instead of mislabelling a dead session as
"assistant broken", error toasts persist (capped at 4) with a close button, and
the journal's built-but-unreachable date filter gets a UI. Gadfly round handled
(public-page 100dvh, toast cap, shared date-input class). Larger deferred items
(revision retention, agent toolbox gaps) noted for their own issues.
2026-07-22 03:54:02 +00:00
steveandClaude Opus 4.8 757ac7394d Address Gadfly findings on #85
Build image / build-and-push (push) Successful in 16s
- PublicGardenPage had the same 100vh mobile bug I fixed in the editor — 100dvh
  there too, so the fix is consistent across both full-height pages.
- Cap the toast stack at 4 (drop oldest). Now that error toasts don't auto-
  dismiss, a burst of failures could otherwise grow the stack unbounded and push
  the newest — the one that just happened — off-screen.
- Hoist the From/To date-input styling to a shared dateInputClass const so the
  two don't drift.

Not taken: reusing safeRedirectPath for the 401 redirect (it validates an
incoming redirect param, it doesn't build the outgoing one — the two uses don't
overlap), and refactoring the three-branch filter spread (it's clear at three
fields; a helper earns its keep when there are more).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:53:18 -04:00
steveandClaude Opus 4.8 156b3fd14c Seed-packet capture: vision model, extraction, catalog match, create (backend) (#81)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 9m44s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m44s
Photograph a seed packet → it fills in the plant and the purchase. This is the
backend; the scan UI is a follow-up PR.

Vision model config (mirrors the agent model from #79):
- Migration 0011 adds instance_settings.vision_model; PANSY_VISION_MODEL is the
  env default. Precedence Settings → env → empty; the KEY stays in the env.
- EffectiveVision resolves it; /capabilities advertises "vision" only when a
  model + key are configured, so the UI offers the scan button only when it works.

Extraction is one-shot, NOT an agent loop (internal/vision):
- majordomo.Generate[SeedPacket] derives a JSON schema from the struct tags and
  hands the image to the vision model; it can't call a tool, so it can't touch
  the garden — it only reads a picture and returns data. Numeric fields are
  pointers, so a field the packet doesn't print comes back nil, not a made-up 0.
- Hermetic test: majordomo's fake provider returns canned packet JSON and
  Generate unmarshals it, image + derived schema included. No live model.

The image is normalized to JPEG at the upload boundary (imagenorm from #80),
which is where an iPhone HEIC becomes readable — majordomo's media path can't
decode HEIC. imagenorm now links into the binary (~7 MB, the cost #80 deferred).

The hard part is catalog matching, not OCR (internal/service/seed_packet.go):
- A wrong auto-match splits a variety's seed-lot history across duplicate rows,
  so the service NEVER auto-creates. matchPlants surfaces RANKED candidates
  (exact name → variety-in-name → same species, conservative and name-based),
  the user confirms, and CreateFromPacket makes the plant (new or existing) + the
  lot. Exactly one of plantId/newPlant, refused otherwise.
- Plants/lots aren't in the undo history (catalog/inventory), so no change set.
- The extractor is injectable (service.WithPacketExtractor) so ExtractSeedPacket
  and the /scan endpoint test end to end against a fake, no live model.

Endpoints: POST /seed-lots/scan (multipart image → proposal, reads only; extends
the read deadline for a slow phone upload, caps the body, maps too-large/unreadable
to clear statuses) and POST /seed-lots/from-packet (confirmed proposal → 201).

Docs: README (PANSY_VISION_MODEL), DESIGN (routes + the decision and why the
model can't touch the garden).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:50:50 -04:00
steve e3d8e01e5b Make the canvas keyboard-reachable + trap focus in dialogs (#92)
Build image / build-and-push (push) Successful in 6s
Closes #84 (scoped first slice). Objects are focusable role=button with an
aria-label and Enter/Space selection — which makes the existing arrow-key nudge
reachable by keyboard for the first time — with a :focus-visible ring. The
<svg> gets role=application + label + title. Modal traps Tab (robustly: pulls
back focus fallen to <body> when a control is removed/disabled) and restores
focus to the opener if it's still in the DOM. Verified live with real keyboard.
Gadfly round addressed (canonical kind label, aria-current, trap robustness).
2026-07-22 03:34:07 +00:00
steveandClaude Opus 4.8 4b348dcbc0 Address Gadfly findings on #84
Build image / build-and-push (push) Successful in 10s
- Object aria-label uses kindDef().label (the canonical "In-ground") instead of
  an ad-hoc kind.replace() that produced "in ground" and diverged from the UI.
  4+ models flagged this.
- aria-current, not aria-pressed, for the selected object — selection isn't a
  toggle, which is what aria-pressed means; aria-current marks the active item.
- Modal focus trap made robust: if focus is NOT inside the dialog (fell to
  <body> because the focused control was removed — ShareGardenModal's
  remove-share button — or disabled while busy, or externally stolen), Tab now
  pulls it back in instead of escaping. The previous branches only handled
  focus being exactly at a known boundary.
- Focus restore checks opener.isConnected before calling focus(): the delete/
  clear flows this targets often remove the element that opened the dialog, and
  a disconnected node's focus() silently no-ops.
- Hoisted the focusable-element selector to a module constant, and excluded
  input[type="hidden"] (it matched input:not([disabled]) and, at a boundary,
  broke the wrap).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:33:38 -04:00
steveandClaude Opus 4.8 d82db48e4b Rough edges: mobile 100dvh, session-expiry in chat, sticky error toasts, journal date filter (#85)
Build image / build-and-push (push) Successful in 30s
Gadfly review (reusable) / review (pull_request) Successful in 5m41s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m41s
The safe, self-contained wins from the #85 bundle:

- **Mobile viewport.** The editor used `100vh`, which on mobile Safari/Chrome is
  the LARGEST viewport (URL bar hidden), so with the bar visible the canvas
  bottom and Fit button were pushed under the browser chrome. `100dvh` fixes it.
- **Session expiry mid-chat.** A 401 during a chat turn was mapped to "the
  assistant is not available right now" — sending the user to debug a config
  problem that isn't there. Now it says the session expired and redirects to
  /login, preserving the path, like the rest of the app treats 401.
- **Error toasts persist.** Error toasts were the primary report that a mutation
  failed, yet auto-dismissed at 4s with no way to retrieve them — look away and
  it's gone. Errors now stay until dismissed; info toasts still time out; both
  get a close button.
- **Journal date filter.** `from`/`to` existed in the API and JournalFilter but
  had no UI, so "show me last spring" was unreachable. Added two date inputs
  (with a Clear) that feed the existing filter. No backend change.

Deferred to their own follow-ups (too big for this bundle, or need a decision):
revision-history retention/pruning, the agent toolbox's missing corrective tools,
plop-level journal entries from the UI, and the editor's max-width cap (a layout
call worth confirming rather than changing blind). The DESIGN.md API-listing
drift the issue mentioned was already fixed in #89.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:31:10 -04:00
steve 20bf7ee03d Image normalization: decode HEIC/webp/png/jpeg → JPEG (#91)
Build image / build-and-push (push) Successful in 7s
Closes #80. internal/imagenorm.Normalize decodes jpeg/png/heic/webp and
re-encodes JPEG, so the seed-packet path (and majordomo's vision) only ever see
a format they can read — HEIC (iPhone default) included. CGO stays off: heic via
libheif-as-WASM, webp pure Go. Hostile-upload bounds: byte cap, overflow-safe
pre-decode pixel/dimension guard, and a recover around the third-party decoders.
The ~5 MB only links in when #81 imports it. Gadfly's blocking round addressed
(panic recovery, the untested pixel-bomb guard now tested, sentinel errors,
lowered pixel cap); EXIF orientation deferred to the #81 handler.
2026-07-22 03:27:32 +00:00
steveandClaude Opus 4.8 cb594f34d8 Address Gadfly findings on #80
Build image / build-and-push (push) Successful in 6s
Security / correctness (multi-model):
- Wrap the decoders in a recover (decodeSafely): a malformed HEIC/WebP that
  panics libheif-via-WASM or x/image/webp now fails this one request as
  ErrUnsupported instead of taking the process down.
- Make the pixel-bomb guard overflow-safe: check each side against maxDimension
  BEFORE multiplying, so a header claiming ~2^32 on a side can't wrap
  int64(w)*int64(h) negative and slip past the pixel-count cap.
- Lower maxDecodePixels 100 MP → 50 MP (~200 MB peak), still above any current
  phone sensor, capping the amplification a small hostile header can force.
- Sentinel errors use errors.New, not fmt.Errorf without a verb.

The finding 5 models agreed on: the decompression-bomb guard was UNTESTED and a
stale comment implied otherwise. Added TestNormalizeRejectsPixelBomb, which
crafts a ~40-byte PNG (valid IHDR + CRC) claiming a huge canvas and asserts
ErrTooLarge from DecodeConfig alone, before any bitmap is allocated — covering
both the per-side and the area trip. Fixed the stale comment.

Error-handling / docs:
- format is now "" on ALL error paths (was populated on some, empty on others).
- Doc rewritten to state the actual error contract (read/encode I/O → wrapped,
  not a sentinel) and to stop referencing a non-existent TestNormalize / TODO.
- Guard io.LimitReader's +1 against an int64 overflow at an absurd MaxBytes.

Tests / provenance:
- Downscale test derives its numbers from DefaultMaxDim instead of hard-coding.
- Check the previously-ignored DecodeConfig error in the small-image case.
- testdata/README documents where sample.heic/webp came from.

Deferred to the #81 upload handler, documented in the Normalize doc: EXIF
orientation (best fixed and tested with a real oriented photo end-to-end) and
context cancellation (image.Decode isn't cancellable mid-decode; the caller
runs it under a timeout, and the size guards keep the work finite regardless).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:27:00 -04:00
steveandClaude Opus 4.8 bfc5d9a871 Make the canvas keyboard-reachable + trap focus in dialogs (#84)
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Successful in 7m40s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m41s
The arrow-key nudge handler existed but only ever acted on a POINTER
selection, and nothing could select without a mouse — so the feature was
unusable by exactly the keyboard users it's for. This is the scoped first
slice: give the canvas a keyboard path in, and fix the Modal focus trap that
every destructive confirmation goes through.

Canvas:
- The <svg> gets role="application" + an aria-label describing the controls,
  and a <title> naming the garden — a screen reader now announces an
  interactive canvas rather than an empty graphic.
- Each object <g> is a focusable role="button" with an aria-label (name +
  kind) and aria-pressed reflecting selection. Enter/Space selects it — the
  step that was missing — which makes the existing arrow-key nudge reachable.
- A :focus-visible CSS rule draws a dashed accent ring on keyboard focus (and
  NOT on a mouse click, which is the point of :focus-visible). CSS rather than
  React state because onFocus on an SVG <g> is unreliable, and a CSS rule
  cleanly overrides the shape's inline stroke.

Modal (blast radius: DeleteGarden/ClearBed/DeletePlant/DeleteSeedLot/Share):
- Tab is trapped inside the dialog and wraps at the ends, instead of walking
  out into the page behind the backdrop.
- On close, focus returns to the element that opened the dialog rather than
  landing on <body>.

Verified live against the built binary with real keyboard input: Tab focuses
an object (SVG <g tabindex> genuinely takes focus), Enter flips aria-pressed
false→true, the focus-visible dash renders (computed stroke-dasharray "5px,
4px"), the dialog traps focus through 5 Tabs, and Escape closes it and
restores focus to the opener.

Follow-ups noted, not done here: object dimensions in the aria-label (needs the
garden's unit context this component doesn't hold), roving-tabindex between
plops inside a focused bed, and the EditorRail tablist semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:20:47 -04:00