Files
pad/web/e2e
xarmian a0bb31d33b feat(web): mount the detail pane on the full-page item host (TASK-2174) (#980)
* feat(web): mount the detail pane on the full-page item host (TASK-2174)

PLAN-2154 Phase 2 / Architecture E, bullet 5 — the Q1 payoff. The full-page
item route (`[collection]/[slug]/+page.svelte`), previously a 47-line wrapper
that rendered `<ItemDetail>` full-page, now mounts the SAME right-docked detail
pane the collection page carries beside the master. From a full-page item,
clicking a child / related / wiki-linked item opens a navigable mini-browser
pane BESIDE the master — the master is the `[slug]` PATH param, the pane is the
`?item=` QUERY param, no collision.

- Reuse the shared `PaneHost` shell, `createPaneController`, and the `paneMint`
  provider-mint settle. Collection-specific deps (j/k pane-follow,
  list-row focus-return, quick-create draft guard) are correct no-ops here.
- Layout: a flex-row `.item-page-host` fills `.main-content` and clips, with an
  `.item-page` overflow column as the master's own scroll container (always, so
  scroll restoration is consistent) — mirrors the collection host's `.pane-open`
  overflow handling. `createScrollRestoration` gets the `.item-page`
  `scrollTarget` getter (TASK-2171); `export const snapshot` is kept.
- Freeze: `peeking={!!openItemRef}` makes the master retain-alive read-only
  (TASK-2172) — never a provider teardown. `onIdentity` captures the master's
  resolved {id,ref,slug} (TASK-2173).
- Master content-links FIRST-OPEN the pane (`openItemPaneByRef`, a depth-0
  paneOwned:true push); pane content-links DRILL (`navigatePaneTo`). Both go
  through the `?item == master` guard, plus a cold-load strip effect that drops
  a hand-crafted `?item=<master>` once identity resolves — never a second collab
  provider on the master's own room.
- Depth-aware ESC mirrors the collection host's escape-stack `pane` slot minus
  the list-focus step: depth>0 pops one level (fenced `handlePaneBack`); depth 0
  closes via the shared escape stack.

paneHostController: extract `openItemPane(item)`'s body into `openItemPaneByRef
(ref)` and make `openItemPane` a thin `openItemPaneByRef(itemUrlId(item))`
wrapper — behavior byte-identical, so the collection page's pane e2e stays green.

The full-page cross-collection BUG-2129 fence e2e (pane-collection-migration-
race) navigated A->B via a plain relationship-link click, which TASK-2174 now
intercepts to open a pane; adapt it to reach the same same-instance
cross-collection full-page nav via the pane's expand. Add pane-full-page-host
e2e for the new host (open/read-only-master/drill/back/close/self-ref strip).

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

* fix(web): harden the full-page pane host self-guard + ESC (Codex round 1)

- P1 (race): gate the pane MOUNT on a resolved, confirmed-non-self identity
  (`showPane`), not just the after-the-fact `?item=` strip. A cold
  `?item=<master-alias>` no longer transiently mounts a 2nd collab provider on
  the master's own room before the strip's `goto` fires. Click-driven opens are
  unaffected (master identity already resolved); only a cold `?item=` load waits
  one master-load beat (master-first).
- P2 (equivalence): make `isMaster` ref-NUMBER aware (case-insensitive prefix,
  zero-padding), mirroring paneTarget's `matchesRefNumber`, so `doc-5` /
  `DOC-005` / a stale pre-move prefix that resolve to the master are caught by
  the self-guard + cold-load strip, not just the byte-exact canonical ref.
- P3 (ESC): defer the host's ESC when a MASTER (non-embedded) graph drawer is
  open — it keeps its own window ESC listener, so one press must close only the
  graph, not the graph AND the pane. Scoped to a `.graph-drawer` outside
  `.item-pane` (the pane's own graph stays in the escape stack).

Add an e2e for the legitimate cold-load `?item=<other item>` path so the
mount-gate can't regress into suppressing valid cross-item cold loads.

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

* fix(web): provenance-correct master guard + refined graph ESC (Codex round 2)

- P1 (provenance): the hand-rolled `isMaster` checked slug before ref-number,
  but a bare `?item=` resolves REF-BEFORE-SLUG server-side — so a ref-shaped
  SLUG (master #5 slugged `plan-6`) was misclassified as the master, wrongly
  stripping/blocking a legitimate open of item #6. Reuse the shared, unit-tested
  `isSamePaneTarget` / `resolvePaneTarget` instead: guards call
  `resolvePaneTarget(target, masterItem)` (self-guard for free), and the
  cold-load strip + mount gate call `isSamePaneTarget({ href }, masterItem)` (the
  documented bare-`?item=` href channel, ref-before-slug). Narrow paneTarget's
  `current` param to `PaneGuardItem = Pick<Item,'id'|'slug'|'item_number'>` so
  the host can pass the master's resolved identity (item_number parsed from ref)
  without fabricating a full Item; a full Item stays assignable, so every
  existing caller is unchanged.
- P2 (ESC): only defer to the master graph's standalone listener when it's the
  FRONTMOST ESC concern (no pane graph drawer at ESCAPE_PRIORITY.graphDrawer is
  the top stack layer). When a pane graph IS the top layer it's innermost and
  must close first via runTopEscape.

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

* fix(web): pane-host ESC layering, block-drag scroll, print (Codex round 3)

- Dual-graph ESC: when a master graph AND a pane graph are both open, the host
  closes the innermost pane graph via runTopEscape, but the master graph's
  uncoordinated window listener ignores preventDefault. Add a scoped
  `stopImmediatePropagation()` (only when a master graph exists) so one ESC
  closes exactly one layer; the host's window listener registers before the
  master graph's, so it runs first.
- Block-drag auto-scroll: `block-drag-handle.ts` hard-coded `.main-content` as
  the scroll container, which no longer scrolls beneath the full-page host's
  `.item-page` column (and never matched the docked pane's `.item-pane`). Walk
  to the innermost actually-scrollable ancestor by computed overflow-y instead —
  class-agnostic (the host's scroll column shares `.item-page` with ItemDetail's
  non-scrolling inner wrapper) and also fixes the pre-existing pane-editor case.
- Print: hide the docked pane + divider and un-clip the flex-row host so
  Ctrl/Cmd+P captures only the master document (not two items side-by-side with
  duplicate footers), mirroring app.css's shell/main-content print unlock which
  doesn't reach these route-owned containers.

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

* fix(web): suppress master-graph listener on held-ESC repeats (Codex round 4)

In the dual-graph case (master graph + pane graph both open), a HELD ESC's
auto-repeats reached the master-graph deferral (or exited the repeat branch)
without stopImmediatePropagation, so the master graph's uncoordinated window
listener (which doesn't bail on e.repeat) closed the second graph during the
same held press. Move the repeat bail before the master-graph deferral and
stopImmediatePropagation on repeats when a master graph coexists, so one
press/hold closes exactly one layer.

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

* fix(web): gate master identity on ref freshness (Codex round 5)

masterIdentity (from onIdentity) LAGS a same-route master navigation: expanding
a pane item to the full page, then browser-Back to <prev-master>?item=<that
item>, reuses this route, so masterIdentity still holds the expanded item until
the master reloads the previous one. The self-guard/strip then compared the bare
?item= against the WRONG (previous) master and stripped a still-valid pane
(Expand->Back lost the pane). Fold a fresh-for-`ref` check into masterItem (a
$derived over `ref`, so it re-nulls synchronously the instant `ref` changes,
before the strip $effect runs) via the shared isSamePaneTarget; base showPane +
the strip on masterItem instead of raw masterIdentity. Add an Expand->Back
restoration e2e.

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

* fix(web): route master graph through escape stack + pathname master freshness (Codex round 6)

- Master-graph ESC (converging fix): ItemDetail's full-page (non-embedded) graph
  drawer kept its OWN window ESC listener on the now-false assumption of "no
  pane/list contention on the full-page route". The full-page pane host adds a
  pane beside a non-embedded master, and that uncoordinated listener double-closed
  the master graph across ESC paths (dual-graph, held-key, text-edit/dialog
  bails). Route BOTH embedded + non-embedded graphs through the shared escape
  stack (ESCAPE_PRIORITY.graphDrawer); the host's single runTopEscape then closes
  exactly one layer, innermost-first. Simplifies the host ESC handler (drops the
  DOM-scan / stopImmediatePropagation whack-a-mole). Contained: embedded=false
  ItemDetail is only the [slug] route, which owns that top-level listener; the
  collection page's pane graph was already stack-based (unchanged).
- Master freshness (Finding 1, incl. cross-workspace): gate masterItem on a
  pathname-stamp comparison instead of a ref-only check. onIdentity stamps the
  pathname it resolved for; masterItem ($derived over page.url.pathname) drops a
  stale identity the instant the pathname diverges — covering ref, collection AND
  workspace navigations uniformly (a bare ?item= can no longer match a prior
  master that merely shares a path ref across workspaces).

Deferred BUG-2178 (child of PLAN-2154): the pane's collection-rename/move
navigate-away emits collection-host-shaped URLs on the full-page host — a
separable, obscure refinement.

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

* fix(web): close ref-shaped-slug guard hole, UUID identity, bind scroll target (orchestrator round 8)

- P1 (ref-shaped-slug HOLE): the server resolves a bare/href ?item= REF-FIRST
  then falls back to SLUG (internal/store/items.go), so a master #5 slugged
  `plan-6` (no live #6) is reached by the slug fallback — but isMasterRef's
  ref-number channel alone returned false (6!=5), WRONGLY permitting a 2nd
  provider on the master's own collab room. isMasterRef now matches the raw
  string against the master's id/slug too (either interpretation → master); the
  guards also drop a resolved content-link ref that string-equals the master
  slug (closes the href-only editor-link path). Erring toward a match is the safe
  direction for the forbidden D2 collision. Add a ref-shaped-slug cold-load-strip
  e2e (seedDoc "plan" → ref-shaped slug plan-<ts>).
- P2 (UUID master routes): itemMatchesRef (ItemDetail) accepted only slug or
  PREFIX-NUMBER, so a UUID route never fired onIdentity/onReady → the guard +
  scroll-readiness silently disabled there. Add the item.id === itemSlug arm
  (additive; also fixes scroll restoration on UUID routes).
- P2 (scroll-target fragility): bind the outer .item-page column element and pass
  it to createScrollRestoration instead of document.querySelector('.item-page')
  (which also matches ItemDetail's inner wrapper of the same class) — no global
  selector, no collision.

BUG-2178 (collection-rename/move-from-pane URL shaping) remains the deferred,
non-destructive follow-up.

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

* fix(web): mirror server UUID-first precedence in the master guard (round 8 verify)

The raw `candidate === masterItem.slug` check over-blocked a legitimate
`?item=<another item's UUID>` when the master's slug coincidentally equalled
that UUID (the server resolves UUID-FIRST, so it opens the other item, not the
master). isMasterRef now mirrors the server precedence by shape: exact id →
master; a UUID-SHAPED candidate that isn't the master's id → NOT master (don't
fall through to slug); otherwise ref-number OR raw-slug match (still catches the
ref-shaped-slug slug-fallback case). Add an e2e where a master's slug IS another
item's UUID and `?item=<that UUID>` correctly opens the other item.

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

* fix(web): server-confirm the ambiguous ref/UUID-shaped-slug master guard (round 9 verify)

Shape alone can't decide whether a ref/UUID-shaped `?item=` that string-equals
the master's slug is preempted by a live item (UUID/ref-first) or reached by the
server's slug-fallback to the master (absent/archived/cross-workspace item) —
the over-block (round 8) and under-block (round 9) are the two horns of a
client-side-undecidable problem. Resolve it definitively: masterMatchSync now
returns true/false/NULL, and the NULL (uncertain) case — reached ONLY for a
pathological ref/UUID-shaped master slug, never for a normal plain-slug master,
so zero network cost in normal use — is confirmed by resolving `?item=` to its
actual item id via api.items.get and comparing to the master's id. The mount
gate blocks while that resolve is pending (never a 2nd provider on the master's
room before we know); the strip waits for a definite master (never deletes a
`?item=` that might still resolve elsewhere); the content-link guards drop only
a definite-master resolved segment. On resolve error, err toward blocking.

Both edge tests (ref-shaped-slug strip, UUID-slug over-block) now pass through
the server-confirm path.

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

* fix(web): key-associate + de-wedge the server-confirm, drop guard over-block (round 10 verify)

Three fixes to the round-9 server-confirm machinery:
- P1 (wedge): the cancelled-fetch dedup left lastConfirmKey pointing at the
  abandoned key, so revisiting it early-returned with no result — URL blocked,
  no pane, no strip, forever. Replace with an inFlightKey plain-let cleared on
  cancel AND completion, so a cancelled key re-fetches.
- P1 (stale result): confirmedTargetIsMaster was a bare boolean not tied to its
  target, so a prior key's answer could transiently leak to a new uncertain
  target (mount/re-target before confirmation). Store {key, isMaster} and treat
  a key-mismatch as pending (confirmedFor). The effect no longer READS the
  reactive result it writes (dedup is the plain-let inFlightKey) — no CONVE-1688
  read-write cycle.
- P2 (guard over-block): the content-link guards used resolvePaneTarget(target,
  masterItem), whose isSamePaneTarget same-item guard over-blocks the same
  UUID-shaped-slug case (a UUID href == master.slug treated as self even for a
  different live item). Resolve without the current-guard and drop only a
  DEFINITE master alias (masterMatchSync === true); an uncertain alias defers to
  the mount gate's server-confirm.

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

* fix(web): revert to the conservative sync master guard (D2 err-toward-match)

The rounds 9-10 async server-confirm — added to distinguish the two
genuinely-undecidable-by-client cases of a ref/UUID-SHAPED master SLUG
(preempts to a different live item vs falls back to the master) — proved a
poor tradeoff: it introduced real race surface (stale-cache-across-archive, a
popstate/mint-settle remount race, SSE-driven refetch) and a shared-code
fireOpenTarget interaction, all for a scenario that requires a PATHOLOGICAL
master slug (one shaped like a ref or a UUID, never produced by an organic
title).

Revert to the simple, conservative sync guard the orchestrator's finding #1
explicitly directed ("return true whenever the target resolves to the master
under EITHER the ref or the slug interpretation ... erring toward a match is the
safe direction"): isMasterRef matches id OR raw-slug OR ref-number. This is
SAFE — it never allows the forbidden D2 collision (a 2nd provider on the
master's room). Its only cost is a BENIGN over-block in exactly the pathological
case (a `?item=<live different item>` whose string coincidentally equals the
master's ref/UUID-shaped slug declines to open a pane — no collision, no data
loss), which matches the existing shared isSamePaneTarget / fireOpenTarget
behavior. Remove the async apparatus + api import + the UUID-slug precision e2e
(the ref-shaped-slug strip test still covers the conservative slug-match block).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-19 21:25:04 -04:00
..