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.
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)
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]>
(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.)
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>
⚠️ 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.
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:
constfresh=awaithistory.refetch()if(fresh.isError){toast.error('Could not refresh history — try again');return}constlist=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>
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 main2026-08-22 23:40:23 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 samelib/data layer; the presentation is new. The backend is untouched except for one read-only addition (below).How it's built
web/src/styles/index.cssdeclares the handoff'sstyles.cssvariables through Tailwind's@themeunder the same names (plus the--p-*canvas/ink tokens on:root). Dark mode is the handoff'spansy-theme.jsported verbatim and inlined inweb/index.htmlso it overrides the same variables on<html>before first paint.lib/theme.tsis the typed React face; the nav button and Settings → Appearance share one store.components/ui/Icon.tsx); a small pill kit (Button, Dialog with focus trap, Field, Seg, Toggle, Tag, toast).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/reactis gone.lib/monogram.ts), collision-resolved across the whole catalog in catalog order (Tomato T, Thyme Th; Pea P, Pepper Pe), replacing the emoji icons.<garden> — YYYY(lib/plan.ts), since the API keeps no source↔copy link.Judgment calls where the handoff was silent
GET /settingsnow carries anauthview (registration mode, local auth, OIDC issuer, label) — the one backend change, with a test. Display name is read-only (no API for it).Bugs found while driving it, fixed here
Verified
tsc,vitest(123 tests incl. new ones for monograms, plan names, compact ft/in),vite build,go test ./...,gofmt.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
@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 onmaininb6981fband merged into this branch.)🪰 Gadfly — live review status
1/1 reviewers finished · updated 2026-08-22 23:27:25Z
claude-code/sonnet· claude-code — ✅ doneLive status board. Findings are posted in each model's own comment. Advisory only — does not block merge.
🪰 Gadfly review —
claude-code/sonnet(claude-code)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
authViewtoGET /settings— is clean under the security lens:Admin gate verified.
api.goregisters/settingswithh.requireAuth()thenh.requireAdmin(). TheauthViewis 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 onlyregistration(string),localAuth(bool),oidc(bool),oidcIssuer(string), andoidcLabel(string). TheOIDCConfigstruct in the config package also holdsClientIDandClientSecret; neither field is referenced inauthView, andsettingsPayloadonly copies.OIDC.Issuerand.OIDC.ButtonLabel. The test atsettings_test.go:98–102explicitly asserts thatclientId,clientSecret, andoidcClientSecretare absent from the response — a positive control that will catch accidental additions.OIDCIssuerexposure is non-sensitive here. Admin users configured the issuer; returning it for display in the UI reveals nothing an admin doesn't already know, andOIDCReady()requires both issuer andBaseURLbefore 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 (
authViewinsettings.go) is clean from an error-handling perspective — all fields are copied directly from a non-nil config struct, the existing error path forEffectiveConfigfailure 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 whenpts.current.size === 2(verified atCanvas.tsx:318and333), so the destructured[a, b]are always defined;monogram.ts:51'scandidates[candidates.length - 1]fallback is unreachable whencandidatesis 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 butfresh.isErroris never checked. In TanStack Query v5, a failed refetch does not setdatatoundefined— 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, andundo.undo(t)fires a revert on the wrong change set. The?? setsfallback at line 26 is dead code in this path (data is defined, notundefined). 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 equivalentlyfresh.status !== 'success') before usingfresh.data, and bail out (with a toast such as "Couldn't verify history — try again") rather than silently falling through:useUndoLast.tsandhistory.tsin full.Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 10m 0s