mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
ca3428fa07061a8eb966f25b5432d2df19dfd77f
9 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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). |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |