mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
a0bb31d33b
* 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