Files
pad/web/e2e
xarmian eaeeb09ab1 feat(web): pane-navigation controller — depth/ownership state machine (TASK-2157) (#962)
* feat(web): pane-navigation controller — depth/ownership state machine (TASK-2157)

Turn the collection page's split pane (PLAN-2105) into a navigable
mini-browser per PLAN-2154 Architecture A. Depth + session ownership are
stamped in SvelteKit page.state (never raw history.state), so they follow
opaque Back/Forward, survive history.go, and reconstruct on cold-load.

- New pure controller ($lib/collections/paneController.ts): planPaneDrill
  (same-ref guard + soft depth cap + ownership INHERITANCE), planLateralOpen
  (first-open mints ownership / depth-0 re-target / depth>0 stack reset),
  planPaneClose (three-way staged unwind). Fully unit-tested (26 cases).
- navigatePaneTo(target) drill added beside openItemPane; ownership created
  only by first-open, inherited by drills (cold-load base = unowned).
- Three-way ownership-aware staged close: OWNED -> go(-(depth+1)); UNOWNED
  & depth>0 -> go(-depth) then afterNavigate-latched replaceState-delete;
  UNOWNED & depth 0 -> replaceState-delete.
- R14 fence-on-continuation baked in: controllerActionSeq + a one-shot
  afterNavigate latch (seq-fenced, state-rechecked), schedulePaneFollow made
  inert at depth>0 (schedule + fired callback) and cancelled on drill/close,
  an in-flight guard so a rapid gesture can't stack a second history.go.
- depth+ownership preserved through every ?item=-preserving nav:
  updateUrlFilters, the ?graph toggle (ItemDetail), and the collection
  rename onNavigateAway (now replaceState, not push).
- navigatePaneTo exported onto the pane ItemDetail seam for TASK-2158 and
  reachable now via a localStorage-gated __padPaneController test hook.

Tests: 26 unit + 5 Playwright e2e (open/close/j-k, drill/back/same-ref,
detach j/k inertness, cold-load close, detached-row reset). Existing pane
e2e suite (13) still green.

Closes TASK-2157

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra

* fix(web): rebase pane ownership on collection rename + tie latch to its popstate

Codex review of the pane controller (TASK-2157):

- P1 (rename ownership): a collection rename replaceState's /old?item=X ->
  /new?item=X, but every predecessor history entry still points at the now-
  dead OLD slug — carrying paneOwned=true forward made an owned close
  history.go back onto a 404. Ownership means "a live pre-pane entry exists
  to unwind to", which is false after a rename, so onNavigateAway now stamps
  a fresh {paneDepth:0, paneOwned:false} base on the new slug: close drops
  ?item= in place, staying on the valid new route. New e2e covers it.
- P2 (latch): gate the afterNavigate latch on nav.type==='popstate' so only
  its own history.go can consume it; a competing goto/link/form leaves it
  armed until the go settles.
- P1 (owned close discards mid-pane filter changes): documented as the
  plan-mandated R8 behavior — an explicit close is now identical to the
  browser Back that already closed the pane in PLAN-2105 (no deviation).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra

* fix(web): harden pane latch consumption + bypass draft guard on rename

Second Codex round (TASK-2157):

- Latch is no longer dropped by a competing history traversal: run() now
  RETURNS whether it reached its destination (depth collapsed to the base);
  the afterNavigate handler consumes the latch only when run() fires, so an
  unrelated browser Back/Forward during the go's in-flight window leaves the
  latch armed for its own popstate instead of clearing it against the wrong
  entry.
- Collection rename now bypasses the unsaved-draft beforeNavigate guard
  (navigatePaneAfterRename): the server-side rename already committed and the
  route component is reused across the same-route pathname change (drafts
  survive), so a "Stay" prompt could otherwise strand the user on the dead
  old slug with a stale owned stamp.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra

* fix(web): bound pane latch with a fallback timer + stronger reset correlation

Third Codex round (TASK-2157):

- P1 lockup: the "leave the latch armed until run() reaches its destination"
  rule could leave paneNavInFlight() stuck forever if the arming history.go
  was superseded (its own popstate never lands). Add a bounded fallback timer
  (PANE_LATCH_FALLBACK_MS) that best-effort-fires then UNCONDITIONALLY clears
  the latch, so the in-flight guard can never stick. clearPaneLatch() also
  tears down the timer (onDestroy + on consume).
- P2 reset correlation: the detached-open reset now requires the landing
  entry to carry ?item= (the pane base), not just depth 0 — rejecting a
  competing browser Back that landed on the pre-pane (no-?item=) entry.
- P2 rename + browser Back: documented that Back to the old-slug predecessor
  is an inherent rename-in-history limitation (past entries can't be
  rewritten), out of the controller's reach; the imperative close is fixed.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra

* fix(web): fence every controller history.go + split rename vs move nav-away

Fourth Codex round (TASK-2157):

- P1 (move vs rename): onNavigateAway is fired by ItemDetail for BOTH a
  collection rename (/user/ws/NEWSLUG?item=X — pane preserved) AND a
  cross-collection item move (/user/ws/coll/slug — full-page route, no
  ?item=). handlePaneNavigateAway now branches on whether the target keeps
  the pane (?item=): rename gets the rebase-to-unowned + draft-guard bypass;
  a move keeps the ORIGINAL guarded push so its unsaved-draft prompt still
  fires (the collection page unmounts and would lose drafts).
- P2 (duplicate close): the production owned-go close is a one-phase
  history.go(-1) that wasn't fenced, so a double-click ✕ / ESC+click could
  stack a second traversal and overshoot the pre-pane entry. Unify all
  controller traversals (owned close, cold-base close, reset) through
  paneHistoryGo(), which marks navigation in-flight (paneNavInFlight blocks a
  duplicate gesture) until the traversal's own popstate settles or a bounded
  fallback. New e2e asserts a double close lands exactly on the pre-pane URL.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 16:51:45 -04:00
..