Replace the UI with the Organic design handoff #124

Merged
steve merged 4 commits from feat/organic-ui into main 2026-08-22 23:40:23 +00:00
Owner

Implements docs/design_handoff_pansy_ui/ across every screen — login/register, gardens, plants, settings, the editor (desktop + phone), the public view. Same React/Vite/TanStack stack and the same lib/ data layer; the presentation is new. The backend is untouched except for one read-only addition (below).

How it's built

  • Tokens once. web/src/styles/index.css declares the handoff's styles.css variables through Tailwind's @theme under the same names (plus the --p-* canvas/ink tokens on :root). Dark mode is the handoff's pansy-theme.js ported verbatim and inlined in web/index.html so it overrides the same variables on <html> before first paint. lib/theme.ts is the typed React face; the nav button and Settings → Appearance share one store.
  • Lucide glyphs at stroke 2.75 (components/ui/Icon.tsx); a small pill kit (Button, Dialog with focus trap, Field, Seg, Toggle, Tag, toast).
  • Editor canvas (editor/Canvas.tsx) rewritten around the prototype's pointer model: wheel zoom to cursor, two-pointer pinch about the centroid, 3px/7px drag thresholds, 3″ snap (the garden's own grid when it snaps), one PATCH per drop, plops clamped with the half-radius overhang, semantic zoom (monogram ≥ 9px, name ≥ 34px, labels > 54px), ghost + HTML5 drag-and-drop from the toolkit, CSS-transition camera for fit/focus. @use-gesture/react is gone.
  • Two chromes, one tree. The editor measures its container: ≥ 760px is toolkit | plan | rail (Plot/Journal/History/Assistant); below it the phone chrome — header, full-screen canvas, peek panel (≤ 45%) between canvas and mode bar, tool strip, mode bar.
  • Monograms derived from names (lib/monogram.ts), collision-resolved across the whole catalog in catalog order (Tomato T, Thyme Th; Pea P, Pepper Pe), replacing the emoji icons.
  • Seasons: a seg over the years with data (current = live, others read-only) plus "YYYY plan" entries — plan copies named <garden> — YYYY (lib/plan.ts), since the API keeps no source↔copy link.

Judgment calls where the handoff was silent

  • Resize. The prototype has none, but the kinds' fixed sizes can't make a 2′×8′ bed, so the selected object gets corner handles (accent, on the dashed outline).
  • Inspector "Details" keeps the exact fields the product had (W/H/X/Y, rotation, color, plantable, bed grid + snap, notes; plop radius/count/label/planted date/plant swap) behind a disclosure under the designed controls.
  • Tap-to-place makes a one-plant plop (radius = spacing/2) as the handoff specifies — previously a 1.5×spacing clump. "Fill the bed" (rows / clumps) is the bulk tool, in the toolkit and the phone strip.
  • Plant list in the focused toolkit replaces the seed tray + picker; ordered by what this garden was planted with most recently, then alphabetical. One attributable lot auto-attributes; several show a "Which packet?" chooser (never a guess).
  • Garden card gets a pencil (edit name/size/units/grid/notes) and non-owners get a leave action; the handoff only drew share/copy/delete.
  • Settings saves itself (toggle on tap, fields on blur/Enter). "Who gets in" is read-only: GET /settings now carries an auth view (registration mode, local auth, OIDC issuer, label) — the one backend change, with a test. Display name is read-only (no API for it).
  • Dropped (not in the handoff, low value vs. clutter): the localStorage seed tray, the journal date-range filter, the touch nudge pad, a History peek on the phone (the header Undo covers the common case; per-step undo is on desktop).

Bugs found while driving it, fixed here

  • Undo targeted the step before the one just made: the history cache trailed the canvas. The header Undo now re-reads history before reverting, and the editor invalidates history after any successful mutation.
  • The plop placed on desktop wasn't left selected (a cache/store ordering race) — removed the effect that raced.
  • Revert entries read "Undid: Undid: …" (the server summary already carries the prefix).

Verified

  • tsc, vitest (123 tests incl. new ones for monograms, plan names, compact ft/in), vite build, go test ./..., gofmt.
  • Screenshots against the handoff's screenshots/ at 1280×800 and 390×844, light and dark: login, gardens, plants, settings, editor (idle, selected, focused, placing, undo, History, Journal), phone (idle, focus/Plants mode, peek), public view, New-garden dialog.

Docs: README gains "The UI"; DESIGN.md's frontend/editor/API/limits sections updated; CLAUDE.md gets the conventions that follow from the handoff.

🤖 Generated with Claude Code

Implements `docs/design_handoff_pansy_ui/` across every screen — login/register, gardens, plants, settings, the editor (desktop + phone), the public view. Same React/Vite/TanStack stack and the same `lib/` data layer; the presentation is new. The backend is untouched except for one read-only addition (below). ## How it's built - **Tokens once.** `web/src/styles/index.css` declares the handoff's `styles.css` variables through Tailwind's `@theme` under the same names (plus the `--p-*` canvas/ink tokens on `:root`). Dark mode is the handoff's `pansy-theme.js` ported verbatim and inlined in `web/index.html` so it overrides the same variables on `<html>` before first paint. `lib/theme.ts` is the typed React face; the nav button and Settings → Appearance share one store. - **Lucide glyphs** at stroke 2.75 (`components/ui/Icon.tsx`); a small pill kit (Button, Dialog with focus trap, Field, Seg, Toggle, Tag, toast). - **Editor canvas** (`editor/Canvas.tsx`) rewritten around the prototype's pointer model: wheel zoom to cursor, two-pointer pinch about the centroid, 3px/7px drag thresholds, 3″ snap (the garden's own grid when it snaps), one PATCH per drop, plops clamped with the half-radius overhang, semantic zoom (monogram ≥ 9px, name ≥ 34px, labels > 54px), ghost + HTML5 drag-and-drop from the toolkit, CSS-transition camera for fit/focus. `@use-gesture/react` is gone. - **Two chromes, one tree.** The editor measures its container: ≥ 760px is toolkit | plan | rail (Plot/Journal/History/Assistant); below it the phone chrome — header, full-screen canvas, peek panel (≤ 45%) between canvas and mode bar, tool strip, mode bar. - **Monograms** derived from names (`lib/monogram.ts`), collision-resolved across the whole catalog in catalog order (Tomato T, Thyme Th; Pea P, Pepper Pe), replacing the emoji icons. - **Seasons**: a seg over the years with data (current = live, others read-only) plus "YYYY plan" entries — plan copies named `<garden> — YYYY` (`lib/plan.ts`), since the API keeps no source↔copy link. ## Judgment calls where the handoff was silent - **Resize.** The prototype has none, but the kinds' fixed sizes can't make a 2′×8′ bed, so the selected object gets corner handles (accent, on the dashed outline). - **Inspector "Details"** keeps the exact fields the product had (W/H/X/Y, rotation, color, plantable, bed grid + snap, notes; plop radius/count/label/planted date/plant swap) behind a disclosure under the designed controls. - **Tap-to-place makes a one-plant plop** (radius = spacing/2) as the handoff specifies — previously a 1.5×spacing clump. "Fill the bed" (rows / clumps) is the bulk tool, in the toolkit and the phone strip. - **Plant list** in the focused toolkit replaces the seed tray + picker; ordered by what this garden was planted with most recently, then alphabetical. One attributable lot auto-attributes; several show a "Which packet?" chooser (never a guess). - **Garden card** gets a pencil (edit name/size/units/grid/notes) and non-owners get a leave action; the handoff only drew share/copy/delete. - **Settings saves itself** (toggle on tap, fields on blur/Enter). "Who gets in" is read-only: `GET /settings` now carries an `auth` view (registration mode, local auth, OIDC issuer, label) — the one backend change, with a test. Display name is read-only (no API for it). - **Dropped** (not in the handoff, low value vs. clutter): the localStorage seed tray, the journal date-range filter, the touch nudge pad, a History peek on the phone (the header Undo covers the common case; per-step undo is on desktop). ## Bugs found while driving it, fixed here - Undo targeted the step *before* the one just made: the history cache trailed the canvas. The header Undo now re-reads history before reverting, and the editor invalidates history after any successful mutation. - The plop placed on desktop wasn't left selected (a cache/store ordering race) — removed the effect that raced. - Revert entries read "Undid: Undid: …" (the server summary already carries the prefix). ## Verified - `tsc`, `vitest` (123 tests incl. new ones for monograms, plan names, compact ft/in), `vite build`, `go test ./...`, `gofmt`. - Screenshots against the handoff's `screenshots/` at 1280×800 and 390×844, light and dark: login, gardens, plants, settings, editor (idle, selected, focused, placing, undo, History, Journal), phone (idle, focus/Plants mode, peek), public view, New-garden dialog. Docs: README gains "The UI"; DESIGN.md's frontend/editor/API/limits sections updated; CLAUDE.md gets the conventions that follow from the handoff. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
steve added 2 commits 2026-08-22 23:13:13 +00:00
Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
Build image / build-and-push (push) Successful in 31s
Gadfly review (reusable) / review (pull_request) Failing after 1s
Adversarial Review (Gadfly) / review (pull_request) Failing after 1s
52b2c09a9e
The frontend is rebuilt screen by screen from the handoff: warm cream ground,
terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same
React/Vite/TanStack stack and the same lib/ data layer; the presentation is new.

- Tokens: web/src/styles/index.css declares the handoff's styles.css variables
  through Tailwind's @theme under the same names; dark mode is those variables
  overridden on <html> by the handoff's pansy-theme.js, inlined in index.html
  so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit
  (Button, Dialog, Field, Seg, Toggle, Tag, toast).
- Login / Register: the centered column over soft accent circles; OIDC button
  and signup footer still follow /auth/providers.
- Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots
  from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open +
  share/copy/edit/delete; New garden / Share / Plan-a-season dialogs.
- Plants: monogram markers derived from the name (collision-resolved across the
  catalog — replaces emoji icons), category chips, expandable lot cards, the
  scan-packet flow as a two-step dialog that never auto-creates.
- Settings: Appearance (theme seg), Who gets in (read-only sign-in config),
  Garden assistant (self-saving toggle + chat/vision model fields), You.
- Editor: a new canvas with the prototype's pointer model (wheel-to-cursor,
  pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom
  monograms/labels), plus corner resize handles; desktop three-card workspace
  (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px
  of container width, the phone chrome (header, peek panel, tool strip, mode
  bar). Seasons as a segmented control over the years with data plus plan
  copies; Undo re-reads history before reverting the newest step.
- Public read-only view and the register page restyled to match.
- GET /settings gains a read-only `auth` view (registration mode, local auth,
  OIDC issuer) so the Settings page can show what's in force.
- README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped.

Co-Authored-By: Claude Fable 5 <[email protected]>
steve added 1 commit 2026-08-22 23:14:10 +00:00
Co-Authored-By: Claude Fable 5 <[email protected]>
steve added 1 commit 2026-08-22 23:16:59 +00:00
Merge branch 'main' into feat/organic-ui
Build image / build-and-push (push) Successful in 7s
dc9ebbe51b
Author
Owner

@gadfly review

(The first run died at manifest unknown — the stub's pinned reusable workflow pointed at a gadfly image tag that had been pruned from the registry. Re-pinned on main in b6981fb and merged into this branch.)

@gadfly review (The first run died at `manifest unknown` — the stub's pinned reusable workflow pointed at a gadfly image tag that had been pruned from the registry. Re-pinned on `main` in b6981fb and merged into this branch.)

🪰 Gadfly — live review status

1/1 reviewers finished · updated 2026-08-22 23:27:25Z

claude-code/sonnet · claude-code — done

  • security — No material issues found
  • ⚠️ correctness — could not complete
  • error-handling — Minor issues

Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.

<!-- gadfly-status-board --> ## 🪰 Gadfly — live review status 1/1 reviewers finished · updated 2026-08-22 23:27:25Z #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — No material issues found - ⚠️ **correctness** — could not complete - ✅ **error-handling** — Minor issues <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>

🪰 Gadfly review — claude-code/sonnet (claude-code)

⚠️ Large PR (632882 bytes): Gadfly downshifted to a focused, single-model review to stay within budget — coverage is intentionally shallower. Consider splitting the PR for a deeper review.

Verdict: Minor issues · ⚠️ 1/3 lens(es) errored — 3 reviewers: security, correctness, error-handling

🔒 Security — No material issues found

No material issues found.

The one backend change in this PR — adding authView to GET /settings — is clean under the security lens:

  • Admin gate verified. api.go registers /settings with h.requireAuth() then h.requireAdmin(). The authView is never reachable by non-admins, and non-admins already knew the endpoint existed (403, not 404, which is consistent with the CLAUDE.md "ErrForbidden means you can see it but may not do that" rule for capability checks rather than resource visibility).

  • No secrets in authView. The struct exposes only registration (string), localAuth (bool), oidc (bool), oidcIssuer (string), and oidcLabel (string). The OIDCConfig struct in the config package also holds ClientID and ClientSecret; neither field is referenced in authView, and settingsPayload only copies .OIDC.Issuer and .OIDC.ButtonLabel. The test at settings_test.go:98–102 explicitly asserts that clientId, clientSecret, and oidcClientSecret are absent from the response — a positive control that will catch accidental additions.

  • OIDCIssuer exposure is non-sensitive here. Admin users configured the issuer; returning it for display in the UI reveals nothing an admin doesn't already know, and OIDCReady() requires both issuer and BaseURL before reporting OIDC as active.

The frontend changes introduce a Google Fonts dependency (fonts.googleapis.com), which leaks user browser requests to a third party. The README explicitly acknowledges this as a deliberate tradeoff with documented fallback behavior, so it is an accepted design choice rather than an unintentional leak — but operators in privacy-sensitive or air-gapped environments should note it.

🎯 Correctness⚠️ could not complete

⚠️ This reviewer failed to complete: claude -p context deadline exceeded

🧯 Error handling & edge cases — Minor issues

Minor issues

The backend change (authView in settings.go) is clean from an error-handling perspective — all fields are copied directly from a non-nil config struct, the existing error path for EffectiveConfig failure is preserved, and no new code paths ignore errors or leave state inconsistent.

The frontend pointer math is also solid: pinchStart() is only ever called when pts.current.size === 2 (verified at Canvas.tsx:318 and 333), so the destructured [a, b] are always defined; monogram.ts:51's candidates[candidates.length - 1] fallback is unreachable when candidates is empty because the empty-letters case returns early at line 36–39.

One real issue in the new useUndoLast.ts:

  • web/src/editor/useUndoLast.ts:25–27 — refetch error silently uses stale data, undoing the wrong change set.
    history.refetch() is awaited but fresh.isError is never checked. In TanStack Query v5, a failed refetch does not set data to undefined — it keeps the last successful (stale) data. So when the refetch fails after a placement (transient network hiccup), fresh.data?.pages.flatMap(...) returns the stale page list that predates the placement, latestUndoable(list) picks the item before the newest one, and undo.undo(t) fires a revert on the wrong change set. The ?? sets fallback at line 26 is dead code in this path (data is defined, not undefined). The PR description explicitly names "undoing the step before the one you meant" as the worst outcome; this code path can still produce exactly that on a refetch error.
    Fix: check fresh.isError (or equivalently fresh.status !== 'success') before using fresh.data, and bail out (with a toast such as "Couldn't verify history — try again") rather than silently falling through:
    const fresh = await history.refetch()
    if (fresh.isError) { toast.error('Could not refresh history — try again'); return }
    const list = fresh.data?.pages.flatMap((p) => p.changeSets) ?? []
    
    Verified by reading useUndoLast.ts and history.ts in full.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 10m 0s

<!-- gadfly-review:ollama:claude-code/sonnet --> ### 🪰 Gadfly review — `claude-code/sonnet` (claude-code) > ⚠️ Large PR (632882 bytes): Gadfly downshifted to a focused, single-model review to stay within budget — coverage is intentionally shallower. Consider splitting the PR for a deeper review. **Verdict: Minor issues · ⚠️ 1/3 lens(es) errored** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> No material issues found. The one backend change in this PR — adding `authView` to `GET /settings` — is clean under the security lens: - **Admin gate verified.** `api.go` registers `/settings` with `h.requireAuth()` then `h.requireAdmin()`. The `authView` is never reachable by non-admins, and non-admins already knew the endpoint existed (403, not 404, which is consistent with the CLAUDE.md "ErrForbidden means you can see it but may not do that" rule for capability checks rather than resource visibility). - **No secrets in `authView`.** The struct exposes only `registration` (string), `localAuth` (bool), `oidc` (bool), `oidcIssuer` (string), and `oidcLabel` (string). The `OIDCConfig` struct in the config package also holds `ClientID` and `ClientSecret`; neither field is referenced in `authView`, and `settingsPayload` only copies `.OIDC.Issuer` and `.OIDC.ButtonLabel`. The test at `settings_test.go:98–102` explicitly asserts that `clientId`, `clientSecret`, and `oidcClientSecret` are absent from the response — a positive control that will catch accidental additions. - **`OIDCIssuer` exposure is non-sensitive here.** Admin users configured the issuer; returning it for display in the UI reveals nothing an admin doesn't already know, and `OIDCReady()` requires both issuer and `BaseURL` before reporting OIDC as active. The frontend changes introduce a Google Fonts dependency (`fonts.googleapis.com`), which leaks user browser requests to a third party. The README explicitly acknowledges this as a deliberate tradeoff with documented fallback behavior, so it is an accepted design choice rather than an unintentional leak — but operators in privacy-sensitive or air-gapped environments should note it. </details> <details><summary><b>🎯 Correctness</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: claude -p context deadline exceeded </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> **Minor issues** The backend change (`authView` in `settings.go`) is clean from an error-handling perspective — all fields are copied directly from a non-nil config struct, the existing error path for `EffectiveConfig` failure is preserved, and no new code paths ignore errors or leave state inconsistent. The frontend pointer math is also solid: `pinchStart()` is only ever called when `pts.current.size === 2` (verified at `Canvas.tsx:318` and `333`), so the destructured `[a, b]` are always defined; `monogram.ts:51`'s `candidates[candidates.length - 1]` fallback is unreachable when `candidates` is empty because the empty-letters case returns early at line 36–39. One real issue in the new `useUndoLast.ts`: - **`web/src/editor/useUndoLast.ts:25–27` — refetch error silently uses stale data, undoing the wrong change set.** `history.refetch()` is awaited but `fresh.isError` is never checked. In TanStack Query v5, a failed refetch does **not** set `data` to `undefined` — it keeps the last successful (stale) data. So when the refetch fails after a placement (transient network hiccup), `fresh.data?.pages.flatMap(...)` returns the stale page list that predates the placement, `latestUndoable(list)` picks the item before the newest one, and `undo.undo(t)` fires a revert on the wrong change set. The `?? sets` fallback at line 26 is dead code in this path (data is defined, not `undefined`). The PR description explicitly names "undoing the step before the one you meant" as the worst outcome; this code path can still produce exactly that on a refetch error. **Fix:** check `fresh.isError` (or equivalently `fresh.status !== 'success'`) before using `fresh.data`, and bail out (with a toast such as "Couldn't verify history — try again") rather than silently falling through: ```ts const fresh = await history.refetch() if (fresh.isError) { toast.error('Could not refresh history — try again'); return } const list = fresh.data?.pages.flatMap((p) => p.changeSets) ?? [] ``` *Verified by reading `useUndoLast.ts` and `history.ts` in full.* </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 10m 0s</sub>
steve added 1 commit 2026-08-22 23:36:53 +00:00
Address #124 review: a failed history refetch must not undo anything
Build image / build-and-push (push) Successful in 11s
2af79012e4
Gadfly (error-handling lens): react-query keeps the stale pages in `data`
when a refetch fails, so `useUndoLast` would fall through and revert the step
BEFORE the one just made — the exact outcome the refetch exists to prevent.
Bail out with a toast unless the refetch succeeded.

Co-Authored-By: Claude Fable 5 <[email protected]>
steve merged commit 05392ee0db into main 2026-08-22 23:40:23 +00:00
Sign in to join this conversation.