Commit Graph

9 Commits

Author SHA1 Message Date
xarmian 01a94d93a8 feat(web): Menu/MenuItem primitive — 3 menus migrated, escape-stack + portal + pointerdown dismissal (TASK-2292) (#1022)
* feat(web): Menu/MenuItem primitive — escape-stack ESC, portal mode, pointerdown outside-click (TASK-2292)

PLAN-2290 Phase 2, PR 4 (final primitive). New shared machinery:

- lib/components/common/Menu.svelte — anchored + portal modes (portal =
  fixed coords + flip/clamp, escapes card content-visibility containment),
  instance-scoped POINTERDOWN outside-click (structurally removes the
  BUG-2281 stopPropagation-on-rows detach workaround), ESC via the shared
  escapeStack at new priority menu=40 (one ESC closes menu before
  pane/drawer), roving keyboard nav, focus-in/focus-return, BottomSheet
  swap at 768px, --bg-raised panel skin.
- lib/components/common/MenuItem.svelte — icon/hint/danger/menuitemradio rows.
- lib/utils/clickOutside.ts + lib/utils/portalAction.ts — extracted from
  the hand-copied per-menu versions.
- app.css: --bg-raised token (dark = tertiary; light = white).

Migrated: ItemActionsMenu (portal mode, entire hand-rolled machinery
deleted), QuickActionsMenu (anchored + sheetOnMobile, EmojiPicker exemption
via exempt(), BUG-2281 workarounds removed), TopBar user menu (desktop +
mobile branches deduped into one snippet; gains aria-haspopup/expanded +
keyboard nav it never had). E2E locators updated to accessible-name form.

Documented leave-alones: TopBar workspace-overflow menu (it IS a dndzone —
conditional mount / focus-steal / pointerdown-close each break
drag-reorder; in-file comments), LaneActionsMenu drill-down +
WorkspaceSwitcher (Phase 3 / later).

Gates: svelte-check 0 errors, 488 tests, make check green; runtime-verified
via Playwright: user menu roving nav (ArrowDown x2 -> Admin), ESC closes via
stack, kebab portal placement + edge-aware rows.

* fix(web): Menu review fixes — form focus hand-off, drag suppression, scroll-close without refocus, resize close

Codex findings on #1022: (1) QuickActions create-form now receives focus
when it swaps in (the focused MenuItem unmounts on the flip); (2)
clickOutside gains suppress() and TopBar's user menu passes
isDragging||dragArmed so pill drags can't slam it shut (parity with the
old drag guard); (3) portal scroll/resize dismissal calls onclose()
directly — no trigger refocus fighting the user's scroll (parity with the
old returnFocus=false); (4) resize now also closes portal menus (stale
fixed coords).
2026-07-24 16:11:32 -04:00
xarmian faf9b3734a feat(web): default new collections to Board — schema-aware (IDEA-2274, IDEA-2287) (#1015)
* feat(web): default new collections to Board view (IDEA-2274)

Board becomes the baseline default view for new collections; existing
collections keep their stored default_view (no migration).

- Frontend fallback (settingsDefaults, collection-page defaultMode,
  shareView coerce, initial viewMode) -> board
- Create/Edit collection modals default -> board
- Backend template seeds (defaults.go, templates*.go) list -> board for
  ideas/plans/docs/hiring/interviewing collections (tasks was already board)
- CLI `pad collection create` and MCP mapCollectionCreate defaults -> board
- Curated create-modal presets with deliberate list curation (Meeting
  Notes, Decisions, OKRs) intentionally left as list
- Pin the three list-keyboard-nav pane E2E tests to ?view=list

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

* fix(web): board default reaches public share page + ItemDetail fallback (Codex round 1)

Codex review found the public share route (s/[token]) derives its owner
default view via a separate `?? 'list'` fallback that bypassed the
coerceSettings change, so settings-less/legacy collections rendered List
on public share pages. Align it (and the pre-init selectedBase) to board.
Also align ItemDetail's inline CollectionSettings fallback (default_view
is unused there, but keep it consistent with settingsDefaults).

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

* fix(collections): group Contacts board by relationship, not status (Codex round 2)

Contacts has no `status` field, so defaulting it to Board grouped by the
default `status` rendered every card in a single Uncategorized lane. Set
BoardGroupBy=relationship so the board shows real lanes. All other
board-defaulted seed collections have a status field or an explicit
board_group_by (verified: Companies/Conventions/Playbooks/Docs have status).

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

* fix(web): always serialize ?view= so a List URL survives a board default (Codex round 3)

buildCollectionUrlParams treated List as the implicit URL view and omitted
it. With Board now a possible collection default (IDEA-2274), a List
selection on a board-default collection produced a URL that, when copied or
opened without the sender's localStorage, resolved back to Board. Always
serialize the view mode; add a covering unit test. Verified the pane E2E
suite (URL-equality assertions) stays green.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-23 13:33:01 -04:00
xarmian 8c710e1db9 fix(web): stop paneOverlay ref-count effect self-looping on mobile (BUG-2284) (#1009)
PR #1007 (TASK-2131) added a PaneHost `$effect` that calls
`paneOverlay.enter()`/`leave()` to inert the app-shell chrome behind the
mobile detail-pane overlay. `enter()`'s `overlayCount += 1` READS
`overlayCount` inside that tracked effect scope, so the effect took a
reactive dependency on the very signal it writes: enter() dirtied the
effect → it re-ran → enter()d again → `effect_update_depth_exceeded`.
Svelte aborts the flush, stranding the rest of the subtree's reactivity —
`paneMintForRoute` stopped recomputing, so the mobile pane (and its Back
chevron) rendered EMPTY. The E2E `pane-controller` mobile-overlay tests
caught it; #1007's own manual check verified the ARIA attributes but not
that item content still rendered.

Fix: `untrack` the count read in enter()/leave() so a write from an effect
never establishes a self-dependency (the write still notifies the layout
reader). The ref-count mutators are written from effects by design, so the
untrack belongs in the store.

Also fixes the second collision from the same #1007 change: the pane is now
`role="dialog"` on mobile, so pane-controller.spec.ts:771's `[role="dialog"]`
+ text locator matched BOTH the pane and the BottomSheet (strict-mode
violation). Target the sheet by accessible name ("Quick actions") instead —
the pane's is "Item detail".

The effect_update_depth_exceeded runaway only manifests under the real
browser scheduler (not jsdom/vitest), so the E2E overlay tests own the loop
regression; the unit tests lock the ref-count semantics.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 19:30:11 -04:00
xarmian 34233a2a25 fix(e2e): de-flake pane j/k re-target test by opening the first row (BUG-2279) (#1003)
pane-controller.spec.ts:160 flaked ~50% (fails once, passes on retry). Root
cause via instrumentation: the test opened a NAMED seeded row and pressed `j`
(down) expecting the pane to re-target to a different item. But the two seeds
share a same-second created_at, so their list order is a non-deterministic
tie-break (BUG-2270) — the named row could land LAST, where `j` clamps at the
final index (Math.min(idx+1, len-1)) and the cursor doesn't move. The
pane-follow then correctly finds the focused row is already the paned item and
skips (no re-target), so `openItemParam` stays put and the assertion fails.

Not a product bug — the follow logic behaves correctly. Fix is test-only: open
the FIRST rendered row instead of a named one, so `j` always has a row beneath
it to move to, regardless of seed tie-break order.

Verified: :160 now 10/10 stable in isolation (was ~50-60% flaky); full
pane-controller.spec.ts 21 passed.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 07:14:57 -04:00
xarmian 57da084a32 feat(web): focus per hop for the detail-pane mini-browser (TASK-2162) (#972)
* feat(web): focus per hop for the detail-pane mini-browser (TASK-2162)

PLAN-2154 Architecture C / R1. Each in-pane hop (a content-link drill or
an in-pane Back) changes `?item=`, remounting ItemDetail's `{#key itemSlug}`
subtrees and destroying the just-activated link/row; `keepFocus` then drops
focus to `<body>`, where the next `j`/`k` runs list-nav and can laterally
re-target the drilled `?item=`, corrupting the stack.

Host now owns focus-per-hop: `focusPaneRegion` moves focus onto the STABLE
aria-labeled `<aside>` synchronously at each drill (`navigatePaneTo`) and
Back-button pop (`handlePaneBack`), before the imminent remount can drop it.
This covers the editor-link keyboard path (EditorLinkPopover hides its
popover — removing the focused anchor — synchronously) since it funnels
through the same `navigatePaneTo` chokepoint. A narrowed desktop `focusin`
backstop mirrors the mobile trap, re-pulling focus into the pane only when
it drops to `<body>` from within `.item-pane` (the list stays reachable).
The depth-aware ESC pop removes no focused control, so it deliberately does
not force focus into the pane — that would fight the depth-0 return-to-list.

Resolves the focus edge TASK-2164 deferred here: its component-local
armed-`pendingBackFocus` restore (which could mis-fire onto a pane control
on a coalesced Back-then-Forward no-op) is retired in favor of this
deterministic host-owned focus — the explicit host resolution signal
TASK-2164 round 10 identified as the real fix. `onBack` is now a pure notify.

Tests: focus-per-hop e2e (mouse + keyboard drill land focus in the pane
mid-load with the destination gated so only `focusPaneRegion` could have done
it; j stays inert; in-pane Back keeps focus in the pane). TASK-2164's
Back-chevron focus assertions reconciled to assert focus-in-pane (the actual
acceptance criterion) rather than a specific button. The depth-0 ESC test now
asserts the standard two-level return-to-list-then-close, which focus-per-hop
correctly restores (drilling no longer strands focus on `<body>`).

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

* fix(web): run focus-per-hop on mobile + the ESC pop (Codex round 1)

Two P1s from review:

- focusPaneRegion no-op'd on mobile while pendingBackFocus (which was NOT
  desktop-gated) had been removed, so a mobile Back/drill could strand focus
  on <body> behind the overlay if the mobile trap's focusin missed the
  removal. Focus the stable paneEl on mobile too — a synchronous belt that
  supersedes what pendingBackFocus gave the mobile Back path (j/k is already
  inert on the overlay, so this is purely the a11y "don't lose focus" belt).

- The depth-aware ESC pop skipped focusPaneRegion; if the user had Tabbed to
  a control the pop then removes (e.g. the Back chevron via the header swap),
  focus could strand on <body>. Land focus on the stable paneEl at the pop.
  It targets paneEl (never a removed control) so it needs no focusin, and it
  doesn't fight the depth-0 two-level ESC — that's a separate later press that
  correctly returns focus to the list from within the pane.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 23:30:17 -04:00
xarmian a0299321ca feat(web): in-pane Back chevron for the pane chrome (TASK-2164) (#970)
* feat(web): in-pane Back chevron for the pane chrome (TASK-2164)

Render a Back chevron in the pane header (ItemDetail's embedded-chrome
branch) whenever `page.state.paneDepth > 0` (PLAN-2154 Architecture C).
Wired to the collection host's existing fenced `paneHistoryGo(-1)` /
`paneNavInFlight()` traversal — the same mechanism the depth-aware ESC
handler (TASK-2163) uses — rather than a bare `history.back()`, so a
rapid double-click can't stack a second traversal (R14). Depth is read
reactively via SvelteKit's `page.state` accessor inside ItemDetail
itself, so a cold-loaded shared `?item=` correctly starts at depth 0
with the chevron hidden. Shared markup covers desktop and the mobile
full-screen overlay alike.

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

* fix(web): keep the Back chevron reachable in the pane's minimal header

Every drill sets loading=true, which switches ItemDetail to the
minimal embedded header (previously Close-only) — so a slow or failed
drilled item stranded the user with no way back, worst on mobile where
ESC isn't reachable. Mirror the same depth-gated chevron there (Codex
review round 1).

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

* fix(web): restore focus to the Back chevron after each pop (Codex round 2)

Clicking Back triggers a reload of the drilled-to item, which briefly
sets loading=true and swaps the loaded pane header for the minimal one
— unmounting the just-clicked, focused Back button and dropping focus
to <body>. Without this, a keyboard user popping a multi-level drill
stack loses their place after every press.

Track the intent with two flags (not one): `paneDepth` updates
synchronously with the popstate `history.go(-1)` fires, landing before
`itemSlug` changes and `loadData()` actually sets `loading = true` — a
single-flag effect can observe a stale `loading === false` on that
intermediate run and consume the flag before the real reload cycle
even starts. `backFocusSeenLoading` requires observing `loading` go
true first, so the restore only fires on the matching false after it.

Also fixes a race in the new multi-hop e2e test: `drillTo()` only
awaits `navigatePaneTo`'s synchronous portion (the `goto()` it fires
is fire-and-forget), so firing three drills back-to-back without
polling between them could read stale depth. Poll after each hop, and
assert the Back button keeps focus across presses.

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

* fix(web): key Back-chevron focus restore off itemSlug, not a loading transition (Codex round 3)

Codex round 3 found a real race in the loading-transition-based focus
restore: pressing Back again from the minimal header — while a PRIOR
item is still loading (paneNavInFlight() only fences the history.go
traversal, not the data fetch) — could let the effect latch onto that
stale, already-in-flight cycle instead of its own.

Investigating with instrumentation showed the generation-fenced
version of the fix was actually correct in isolation, but the added
console logging was itself perturbing timing enough to mask a
genuinely flaky window under heavier parallel load. Replaced the
two-flag, generation-counting design with a much simpler one: key off
itemSlug (purely URL-derived, updates synchronously with the same
popstate that stamps paneDepth, independent of fetch status) actually
changing away from the ref captured at click time, then wait for
loading to clear. No transition-observation bookkeeping needed.

Verified with 25+ consecutive passes of the new race-condition e2e
test across workers=1/2/4, including alongside the full pane suite
under system load from concurrent sibling agents.

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

* fix(web): fall back to the Close button after the terminal Back pop (Codex round 4)

The last Back press (landing at depth 0) has no Back button left to
restore focus to, so it was stranding focus on <body> with the pane
still open. Fall back to the always-present Close button — a stable,
keyboard-reachable control — rather than leaving it unhandled. Full
"focus per hop" for arbitrary content-link drills stays TASK-2162's
scope; this only closes the gap the Back button's own terminal press
opened.

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

* fix(web): bind the minimal header's Close button too (Codex round 5)

closeBtnEl (the terminal-Back-pop focus fallback added in the
previous commit) was only bound on the loaded header. If the pop's
destination is slow or fails to load, the minimal header can still be
mounted when the restore effect fires, leaving closeBtnEl undefined
and the restore a silent no-op. Bind it on both headers, mirroring
backBtnEl's existing pattern.

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

* fix(web): fence Back-chevron focus restore against a superseding navigation (Codex round 6)

Comparing only itemSlug treated ANY later navigation as "the Back pop
landed" — so clicking a different row (or hitting Forward) while the
Back destination is still loading would let that unrelated
navigation's itemSlug change satisfy the check, stealing focus into a
pane the user never asked to restore-focus on. Also left the pending
flag armed indefinitely across a quick Back->Forward that never
matched.

Fence with loadGeneration (bumped synchronously at the top of every
loadData() call, regardless of trigger): capture it at click time and
require the settled generation to be EXACTLY one more — the Back
click's own load and nothing else raced in ahead of it. A mismatch
abandons the restore instead of stealing focus, and self-resolves the
"armed indefinitely" case since loadGeneration only increases.

Verified the new regression test actually catches the bug: temporarily
disabled the guard, confirmed the test fails (focus lands on Close),
then restored it and confirmed it passes.

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

* fix(web): bound the Back-chevron focus-restore intent with a timeout (Codex round 7)

The generation fence alone didn't self-resolve every "superseded"
case: a Back immediately reversed by Forward within TASK-2166's
~140ms pane-mint settle window can coalesce to a net no-op — neither
itemSlug nor loadGeneration ever change — leaving pendingBackFocus
armed with nothing left to ever falsify the generation check against.
The next, wholly unrelated single-load navigation would then satisfy
backFocusStartGen + 1 by coincidence and steal focus.

Rather than chase each interleaving individually, bound the pending
intent with a timer (mirroring the host's own PANE_GO_SETTLE_MS "give
up waiting" pattern): if the click's own restore hasn't resolved
within 600ms, disarm unconditionally. Cleared on both the normal
resolve path and onDestroy.

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

* fix(web): replace the blunt Back-focus timeout with a targeted no-op check (Codex round 8)

Round 7's flat 600ms wall-clock timeout disarmed the pending restore
unconditionally, which also cancelled a legitimately slow, still-
in-flight restore — loadData() makes several requests, and a modestly
slow connection can easily outrun a few hundred ms, stranding a
keyboard user's Back press on <body> for the common case, not just
the rare coalesced-no-op one.

Replace it with a single check shortly after TASK-2166's ~140ms
pane-mint settle window: itemSlug updates synchronously with the
popstate (independent of network speed) and loadGeneration bumps
synchronously at the top of loadData(), so a load that's genuinely in
flight has already moved one of the two by the time the check fires.
Only the coalesced-to-nothing case (Back immediately reversed by
Forward) still shows both unchanged — that's the only case the check
disarms. Every other pending restore, however slow, resolves normally
through the existing effect.

Added a dedicated regression test proving a 400ms-delayed (well past
the 200ms check) but legitimate destination load still restores focus
correctly.

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

* fix(web): correct the Back-focus no-op check window against TASK-2166's mint settle (Codex round 9)

An earlier comment claimed itemSlug updates synchronously with the
popstate onBack triggers — true before this branch merged TASK-2166,
false after: that sibling change added a provider-mint settle that
deliberately coalesces EVERY popstate-driven ?item= change onto the
<ItemDetail> ref prop (even a single Back press) behind a
PANE_MINT_SETTLE_MS (140ms) window, so itemSlug only starts moving
~140ms plus the history.go->popstate round-trip after the click,
regardless of network speed.

The 200ms no-op-check window from round 8 started at click time, so
any traversal taking as little as ~60ms beyond the mandatory 140ms
settle could clear pendingBackFocus as a false no-op, stranding a
keyboard user's Back press on <body>. Import PANE_MINT_SETTLE_MS from
the authoritative source and size the check at 2x it for headroom
over the popstate round-trip on top of the settle itself. Also
consolidated the accumulated round-by-round comment trail into a
single numbered summary plus one timing note, since the old inline
diary had an outdated claim baked into it.

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

* fix(web): drop the timer-based Back-focus no-op heuristic (Codex round 10)

Three review rounds (7-9) tried progressively longer wall-clock "give
up waiting" timeouts to disarm a stale pending focus-restore on a
coalesced Back-then-Forward no-op. Each one got shown to be racing an
ever-longer worst-case bound elsewhere in the pipeline: the mint
settle, then the settle plus the popstate round-trip, then
paneHistoryGo's own 500ms fallback on top of both. A fixed-duration
heuristic is structurally the wrong tool for this — closing it for
real needs an explicit "did this specific click's traversal resolve"
signal from the host, which is new cross-component plumbing, not a
hardening pass on the existing effect.

Drop the timer. Keep the generation fence (timing-independent, and
the part that closes the actual bug class Codex round 6 found: an
unrelated navigation superseding a stalled Back pop). Document the
residual gap as a known, narrow, low-severity limitation for TASK-2162
(the already-planned general "focus per hop" follow-up) instead of
continuing to patch it here: worst case, focus lands on a nearby,
still-sensible, keyboard-reachable pane control instead of the ideal
target — not lost, not a correctness or data issue, and it requires a
fairly deliberate Back-then-Forward sequence to trigger at all.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 22:30:01 -04:00
xarmian b14953a94e feat(web): Phase 1 depth-aware ESC — pop one level at depth>0 (TASK-2163) (#967)
* feat(web): depth-aware ESC pops one drill level in the pane (TASK-2163)

At depth>0 in the item pane's mini-browser stack, ESC now runs
history.back() to pop exactly one drill level and consumes the key,
instead of routing through the list-row helpers (returnFocusToList /
resolvePaneReturnTarget) which are meaningless once detached. Only at
depth 0 does ESC fall through to the existing two-level
return-focus-to-list-then-close behavior — unchanged on both desktop
and mobile.

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

* fix(web): route depth-aware ESC through the fenced paneHistoryGo

Codex review round 1: a bare history.back() never set paneGoInFlight,
so repeated ESC presses (or an ESC racing a close/reset click) could
queue a second traversal against stale depth and overshoot. Route the
pop through the existing paneHistoryGo(-1) fence, matching every other
controller history.go call site.

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

* fix(web): ignore auto-repeat ESC keydowns in the depth-aware pop

Codex review round 2: a held ESC key fires many auto-repeat keydowns,
and each one settles the paneHistoryGo in-flight fence before the
next arrives, so one held press could unwind several drill levels
(even closing the pane). Gate the pop on `!e.repeat` so a held key
still consumes ESC but only ever pops the level from the initial
physical press. Adds a Playwright test that dispatches synthetic
repeat:true keydowns and asserts no further level pops.

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

* fix(web): hoist the ESC repeat-guard above the depth-aware branch

Codex review round 3: gating !e.repeat only inside the depth>0 branch
left a leak at the depth 1->0 boundary — the physical press pops to
depth 0, then subsequent repeat:true events (now depth 0, no
.item-pane focus) fell through to runTopEscape() and closed the pane
within the same held key. Move the repeat check to the top of the ESC
chain so every repeat is a pure no-op regardless of what the initial
press already changed. Adds a boundary-crossing Playwright test.

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

* fix(web): detect an open dialog/BottomSheet by existence, not focus

Codex review (2nd pass): the ESC handler's dialog guard checked
whether the EVENT TARGET was inside a dialog/[role=dialog], but
BottomSheet/DockedSheet don't move focus into themselves on open — so
Quick Actions / Move To on mobile left document.activeElement on the
trigger button back inside .item-pane. ESC then fell through this
guard, popped/closed the pane underneath, AND the sheet's own
independent window listener also closed it — two layers from one
press. Switch to an existence check (`dialog[open], [role="dialog"]`)
so it detects an open sheet regardless of focus. `dialog[open]` (not
bare `dialog`) because Modal.svelte's native <dialog> is always
mounted and toggled via showModal()/close(), so a bare existence
check would false-positive on any page with an idle Modal instance.
Adds a mobile Playwright regression test.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 19:51:43 -04:00
xarmian fe04de0f8f feat(web): detach at depth>0 — clear list highlight + gate pane-snap (TASK-2161) (#965)
At stamped pane depth>0 the split-pane detail is a detached mini-browser,
so the collection list must go inert. TASK-2157 already made
schedulePaneFollow bail at schedule time and re-check depth in its fired
callback, cancelled the pending follow on navigatePaneTo, and made a
detached list-row click RESET the stack (never a replace of the drilled
entry). This closes the two remaining detach gaps from PLAN-2154
Architecture C / D-detach:

- focusedItemId (the List/Board/Table row-highlight marker) now gates on
  currentPaneState().paneDepth === 0, so the highlight is CLEARED once the
  pane drills past its base and re-appears on unwind (page.state is
  reactive).
- the pane-snap $effect bails when depth>0, so it no longer snaps the
  cursor to a drilled item that may not even be in the list.

Together with the pre-existing schedulePaneFollow guards, j/k is fully
INERT at depth>0: no pane-follow and no visible highlight movement.

Tests (e2e/pane-controller.spec.ts): a highlight-cleared-and-restored test
(drill clears the row highlight, j/k can't re-introduce it, browser Back
restores it) and the R3 late-timer test (a j/k follow scheduled at depth 0
does not clobber a drill to depth>0). Full pane-controller suite (9) +
pane-a11y-focus (6) + collections vitest (157) green.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 18:41:52 -04:00
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