Commit Graph

67 Commits

Author SHA1 Message Date
xarmian cc26288794 fix(web): share pages render attachment refs as honest placeholders (BUG-2389) (#1135)
The public share route (/s/{token}) rendered item content with a bare
marked() call, so pad-attachment: references fell through as broken
<img src="pad-attachment:..."> tags and dead links. Two halves:

1. CommentThread.svelte is deleted outright — grep proved it was
   unmounted dead code (its only reference was a prose mention in
   ItemDetail.svelte), so its half of the bug resolves by deletion
   rather than by fixing a component nothing renders.

2. The share route now renders through a new opt-in wrapper,
   renderMarkedWithAttachments(), which threads an AttachmentRenderContext
   into the existing marked renderer hooks. With a null resolver and the
   new renderAttachmentUnavailable() placeholder, every ref becomes an
   honest "Attachments aren't available on shared pages yet" chip —
   deliberately NOT the "missing or has been deleted" wording, because
   the attachment exists; the share surface just cannot serve its bytes.
   Sanitization is unchanged: the wrapper returns unsanitized HTML and
   the share page keeps its single DOMPurify pass.

The `missing` hook is a parameter (default: renderAttachmentMissing) so
authed surfaces keep their existing wording, and the wrapper clears the
module context in a finally block so bare marked() callers are
unaffected (pinned by test).

The token-scoped byte endpoint that would serve real images on share
pages (2b) is deliberately NOT built here — it adds a new
unauthenticated ACL surface and is tracked separately pending approval.
A real resolver through the same wrapper is the plug-in point (pinned
by test).

Tests: markdown.shareAttachments.test.ts (6 unit legs incl. bare-marked
opt-in control and context-clearing) and
bug-2389-share-attachment-placeholder.spec.ts (e2e: real upload → item
ref → item share link → anonymous visit; verified failing on the
pre-fix build).

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-17 05:19:25 -04:00
xarmian 2521e3e1c7 fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610) (#1132)
* fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610)

In split view the item pane is an overflow-y:auto scroll container,
which computes overflow-x:auto too — the quick-actions () and ⋯
menus were ANCHORED panels inside it, and a right-aligned panel
opening from the pane's action bar extends left past the pane's edge,
so the container clipped it mid-text (Dave's screenshots: 'age
actions' for 'Manage actions', truncated tagline).

Both menus now use the Menu component's portal mode — built precisely
to escape overflow containment (fixed coords portaled to <body>,
viewport clamping, flip-when-cramped, scroll dismissal), and already
the mode of every board-card menu. Widths cover each menu's content
(QA: the 230px qa-body min-width + chrome; ⋯: the longest row).

Regression e2e uses a PAINT-level oracle — clipping doesn't shrink
getBoundingClientRect, so elementFromPoint just inside the panel's
left edge must resolve to the panel; verified failing on the anchored
control build with the exact reported symptom, plus a geometry
precondition so the probe can't pass vacuously. Existing e2e + unit
lookups that scoped menu rows under .item-pane / the master column
are page-scoped now (the portaled panel lives in <body>; only one
menu is ever open, and the scoped trigger click is what ties it to
its column).

* fixup: scope portal scroll-dismiss to anchor-moving containers — any-scroll dismissal closed pane menus under live SSE churn (found via parallel e2e instability vs a clean control build)

* fixup: codex round 1 — route nav-key bail includes portaled [role=menu] (pre-existing leak for board menus too), stopPropagation on handled menu keys, exempt-aware scroll dismiss, per-menu e2e geometry preconditions

* fixup: page-scope the two graph-drawer menu lookups in pane-content-link-anchors (codex round 2)
2026-08-17 03:24:24 -04:00
xarmian 54526c5b33 fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602) (#1128)
* fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602)

Seven sites assigned ItemDetail's collection snapshot under fences that
ordered STARTS, and loadData's cross-collection escape hatch admitted
any generation-stale write that fetched a different collection — so a
loadData continuation spanning a cross-collection MOVE restored the
SOURCE collection over the freshly adopted TARGET (the live item,
itemGen-fenced, kept the move: the pane rendered the item against the
wrong collection's schema).

All writes now route through adoptCollection, backed by the pure
shouldAdoptCollection decision: (1) a snapshot disagreeing with the
LIVE item's collection_id is vetoed regardless of freshness — id, not
slug, so renames still apply and a reused slug can't satisfy it (this
also closes a latent foreign-write in the SSE collection_updated
refresh, which fetched by slug); (2) same-collection refreshes keep
newest-started-wins; (3) the legitimate cross-collection correction
the old hatch existed for still lands when the live item agrees.

On the veto path, an embedded pane whose collection was still null
(fresh mount — refreshCollectionIfMoved's !collection guard skips
there too) converges on the live item's collection instead of being
left schema-less (adoptOrConvergeToLiveCollection); non-embedded
masters stay route-authoritative per the existing policy.

e2e reproduces the filed race deterministically (route-hold on the
realColl fetch, API move mid-hold, release): the control build renders
the moved item against SrcMarkerField's schema verbatim; the fixed
build converges on the target's.

* fixup: codex round 1 — post-converge myGen re-checks, schema-less error surfacing, pre-fetch convergeGen, hard collection_id oracle in e2e

* fixup: codex round 3 — empty-string collection_id normalizes to no-anchor, else-branch schema-less surfacing, itemGen re-check before singleton claims

* fixup: two stale comments (codex round 5 docs)
2026-08-17 00:27:34 -04:00
xarmian 904878522a fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601) (#1127)
* fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601)

Two stranding layers, both from the same root: a collection rename
changes the slug without touching items, so nothing item-shaped ever
re-announces it.

1. ROUTE: delta-sync catch-up covers item changes only, and /changes
   says nothing about renames — a rename-only gap even reports
   caught_up — so a client that missed the collection_updated SSE
   (replay gap, disconnect) kept a dead route slug; slug-keyed fetches
   404'd until a manual navigation. The collection route now reconciles
   its slug against the live collections list by STABLE collection id
   on every sync pass (resolveSyncRenameTarget — pure, unit-tested —
   wired via reconcileRouteCollectionSlug), mirroring the BUG-2272
   SSE/reorder-404 heals and sharing the renameNav intent tracker.

2. DATA (discovered by this fix's own e2e, present on the LIVE SSE
   path too): cached localIndex rows keep the old collection_slug —
   rows only re-stamp when the item itself changes — so EVERY
   rename-healed route rendered an empty board while the sidebar
   counted the items. New localIndex.retagCollection re-stamps rows by
   stable collection id (search + IDB write-through, upsert pattern),
   called from the workspace layout's global collection_updated
   subscriber (any route, live SSE) and from the sync heal (missed
   SSE, where the layout subscriber also missed the event).

e2e covers both paths with an aborted-SSE missed-event leg and a live
SSE leg; both specs fail on the pre-fix build (verified) and the
missed-SSE spec guards its own vacuity (asserts the strand before
triggering the heal).

* fixup: codex round 1 — foreign-snapshot gate on sync heal, pendingRetags for pre-hydration renames, layout loadCollections widened, goto-failure renameNav reset, worker-unique e2e slugs

* fixup: stamp pendingRetags with recording user's identity; discard on mismatch at warm-hydrate apply (codex round 2)

* fixup: heal the full-page item route's collection segment on sync pass too (codex round 3 — the bug body's own example route)

* fixup: reconcileCollectionSegment switch-safety — pre-await fence, destroyed guard, identity-compared bridge cleanup (codex round 4)

* fixup: compare the read-back $state proxy, not the raw literal (codex round 5)

* fixup: codex round 7 — navigating guard on both healers, replaceState on the list heal, null-owner adoption for pendingRetags, in-window vacuity pins in e2e
2026-08-16 20:52:32 -04:00
xarmian cada8777e7 fix(web): full-page pane host gets its own navigate-away handling (BUG-2178) (#1123)
The full-page item host reused the shared controller's
handlePaneNavigateAway, whose goto targets are collection-host-shaped
(the base route IS the collection page). On this host both emits
abandoned the master: a pane collection-rename landed on the renamed
collection's root page, and a pane item-move hard-navigated to the
moved item's full page.

The host now wires planFullPageNavigateAway (new pure planner in
paneController.ts, next to planPaneDrill and friends):

- COLLECTION RENAME (keeps-pane emit): IGNORE. Nothing the host owns
  is invalidated by the emit alone — if the renamed collection is the
  MASTER's, the master ItemDetail's own BUG-2272 SSE rename handler
  already gotos the new-slug URL with the full search string (?item=
  included), so the route self-heals with the pane intact; a foreign
  collection never touched the master route. The embedded pane needs
  no URL change either — it trusts item.collection_slug.
- ITEM MOVE (no ?item=): RETARGET the pane to the moved item's slug
  via the existing drill machinery (navigatePaneTo), which preserves
  the master pathname by construction and handles depth/ownership/
  focus. When ?item= already held the slug (a same-workspace move
  keeps slugs, so the drill same-ref-guards to a noop) the pane
  self-heals via the item_updated SSE refetch instead.
- Malformed/pathless URLs: IGNORE — staying on the master beats
  navigating somewhere unparseable. (decodeURIComponent throws on
  malformed percent sequences; the hostile-input unit test caught
  that crash before it shipped.)

The controller's handlePaneNavigateAway is annotated collection-host-
only; every property of its spec comment is untouched for that host.

Tests:
- planFullPageNavigateAway unit table (both real emit shapes verbatim,
  encoded slugs, trailing slash, malformed/empty/foreign-origin).
- E2E (pane-full-page-capstone.spec.ts): move the PANE item to another
  collection from the docked pane; assert the pathname never leaves
  the master route and ?item= retargets to the moved slug. Mutation-
  verified against a control binary with the old wiring: it fails with
  ?item= gone and the master abandoned — the reported bug, verbatim.
  The spec header's BUG-2178 deferred note updated to covered.

Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
2026-08-16 15:12:21 -04:00
xarmian e91c4fc261 fix(store): include the cursor's own second in /changes deltas (BUG-2539) (#1086)
* fix(store): include the cursor's own second in /changes deltas (BUG-2539)

items.updated_at / items.deleted_at are RFC3339 whole-second strings
(store.now()), while the /changes cursor is a unix-millisecond value —
normally the previous response's server_time. ItemsModifiedSince
formatted that cursor with the same second precision, truncating it
DOWN, then compared with a strict `>`. Every change landing in the
cursor's own second compared equal and was dropped, permanently: the
caller advances its cursor past that second and nothing reaches back.

User-visible symptom: a bulk archive ~450ms after a page seeded its
cursor left the item rendering as LIVE indefinitely — no banner, no
redirect — while the server had deleted_at set. It was never
archive-specific (updates were dropped identically); a missed update is
usually re-delivered by the next event, a missed deletion never is.

Compare inclusively against the truncated second instead. The boundary
second may be re-delivered, which every consumer of this endpoint
applies idempotently, and it is bounded to one second of changes per
sync. Sub-second storage is the other fix and is a migration, not a
one-liner: these comparisons are lexicographic on TEXT columns and
mixing precisions inverts them ("…20.451Z" sorts BEFORE "…20Z").

Verified against a live instance with four cursors all strictly earlier
than the archive in real time: two inside its second MISS, two in
earlier seconds HIT.

Tests:
- TestItemsModifiedSince_SameSecondCursor — same-second leg plus a
  previous-second control. Fails 3/3 unfixed, passes 3/3 fixed; the
  control passes on both.
- e2e bug-2539-sync-window — the banner must appear in the already-open
  page AND follow a /changes delta that carried the deletion, so a
  reload cannot satisfy it. The 450ms leg fails unfixed; 1200ms control
  passes on both.

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

* test(store,e2e): close the review gaps in the BUG-2539 counterfactuals

Codex review of 7905ed06 found no P1s and three P2s, all on whether the
tests actually measure the fix. Each was right.

Store test:
- It derived "same second" from a wall-clock reading taken BEFORE the
  writes, so a leg whose writes drifted into the next second would pass
  under the unfixed query and still be counted as evidence. It now reads
  the timestamps the writes actually STORED and compares those against
  the cursor's second.
- Misalignment retried instead of skipped, so the leg cannot silently
  stop testing anything.
- It never asserted the archived row comes back in `updated`, leaving
  the `(deleted_at IS NULL OR deleted_at >= ?)` arm free to regress to
  `>` unnoticed. Now asserted.

E2E:
- It accepted ANY /changes response carrying the deletion, including the
  cursor-seeding request setWorkspace fires during load, and never
  established that the page had loaded a live row. Both holes let it
  pass without exercising the incremental path. It now requires the
  page's own item GET to have seen deleted_at null AND the deletion to
  arrive on a /changes that resolved after the archive POST completed.
- Assert check.ok() before reading deleted_at; delete the scratch
  workspace at the end.

Also softened the comment's claim about re-delivery: `>=` makes the
endpoint at-least-once at the boundary and rows can repeat across
several rapid syncs, not just one. What makes that safe is that the
payload is server state rather than an increment, so the comment now
says that instead of "bounded to one second".

Counterfactuals re-run against a genuinely reverted query (the earlier
stash-based attempt was a no-op once the fix was committed, and passed
for that reason): store test fails 3/3 with all three assertions firing,
e2e 450ms leg fails, both controls pass.

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

* test(e2e): decide BUG-2539's oracle by request order, not response timing

Codex's second pass kept one P2 on the e2e, and it was right on both
halves. The oracle compared response arrival against the moment the
archive POST completed, which is racy in both directions: the server
publishes the SSE event BEFORE the bulk handler finishes writing its
response, so the incremental /changes can resolve first and be scored as
"not after the archive" (false failure); and a slow cursor-seeding
response can resolve after it and be scored as incremental (false pass).
The live-row check was existential — any live read of the row counted,
including one issued after the archive.

Both are now decided on the REQUEST side, where ordering is not racy:

- `/changes` requests are numbered as they are issued. The seed that
  setWorkspace fires on mount is number 0; only a LATER one carrying the
  deletion satisfies the assertion.
- Item GETs record their issue time, and `archiveSentAt` is stamped
  immediately BEFORE the POST goes out, so "the page read a live row
  before the archive" is decidable without waiting on anything.
- The async response handlers are collected and awaited before the
  assertions read their flags, instead of racing them.

Cleanup moved into a finally so a failing assertion no longer leaks the
scratch workspace, and its response is checked.

Counterfactual re-run at repeat-each=3 against a genuinely reverted
query: the 450ms leg fails 3/3, the 1200ms control passes 3/3, and both
legs pass 3/3 with the fix in.

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

* test(e2e): anchor BUG-2539's legs to preconditions, not a fixed delay

The fixed 450ms/1200ms offsets measured from navigation start were a
proxy for the real condition, and a machine-speed-dependent one. Under
parallel workers a slow load put the page's own item read AFTER the
archive, so the page rendered an already-archived row and the sync path
was never exercised — the live-row assertion then failed, correctly
reporting that the leg had not reproduced the scenario.

The legs now wait for the two things that actually have to be true —
the page has READ a live row (server-attested deleted_at null) and its
EventSource is subscribed (the /events response headers have arrived;
the handler subscribes before writing them) — and are named after the
mechanism: archive inside the cursor's own second vs after crossing the
next second boundary.

Also from Codex's third pass:
- the /changes URL match is anchored so it cannot also match
  /items-changes, whose requests would otherwise consume ordinals and
  let the cursor seed pass as the incremental sync;
- the incremental check now requires ordinal > 0 AND issue time at/after
  the archive, so a retried seed cannot pass on ordinal alone;
- the live-row check no longer compares clocks at all — a response
  carrying deleted_at null cannot come back after the archive applied,
  so the server attests it;
- the response-handler drain loops until no new handler was queued while
  awaiting, instead of snapshotting the array once;
- cleanup is recorded in `finally` and asserted after it, so a cleanup
  failure cannot replace the real one, and setup now runs inside the try
  so a failed setup cannot leak the scratch workspace.

Counterfactual against a genuinely reverted query: the same-second leg
now fails 6/6 (the delay-based version managed 7/8), control passes 6/6.
130 runs green with the fix in.

Both assertions carry the recorded state in their message: this leg
flaked twice in ~70 runs of an earlier revision and the artifacts were
cleared by the next run before they could be read, so a recurrence has
to explain itself from the failure text.

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

* test(e2e): make BUG-2539's leg PROVE it hit the second it is named after

Codex's fourth pass: the same-second leg never checked that it actually
landed in the cursor's second. It archived immediately after the
preconditions and assumed. On a slow machine that drifts across the
boundary, at which point the reproducing leg quietly becomes a second
control and passes against the very query it exists to convict — the
same "a test that silently stops testing anything" shape the store test
was already hardened against, which is what makes the point land.

Each attempt now compares two SERVER values: the `server_time` of the
page's first /changes (the seed — that value IS the client's
lastSyncTime, the cursor the failing sync used) against the `deleted_at`
the server stored. Same-second leg requires equality, boundary leg
requires difference, and a misaligned attempt is retried on a fresh
workspace (up to 6) instead of asserted on. Exhausting the attempts
fails with both seconds in the message.

Also from that pass:
- cleanup swallows a rejected delete rather than replacing the real
  failure with a transport error;
- `createdSlug` is set from the requested slug BEFORE parsing the
  response, so a malformed success cannot leak a workspace;
- request/response listeners are removed per attempt, so retries do not
  stack handlers;
- the live-row comment now says "carries no deleted_at" — a live item
  omits the field (omitempty) rather than sending null. The previous
  commit message said `deleted_at: null`; the check was always a falsy
  one, so only the wording was wrong.

Counterfactual against a genuinely reverted query: same-second leg fails
4/4, control passes 4/4. 12/12 green with the fix.

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

* test(e2e): scope BUG-2539's matchers per attempt; don't accept an unknown seed

Codex's fifth pass, both findings real:

- Retry ordinals were not scoped to the attempt's workspace. A retry
  reuses the page, which is still showing the previous attempt's
  workspace when the listeners go on, so an in-flight /changes from that
  one could take ordinal 0 and be mistaken for the new seed. The
  /changes, item-GET, and /events matchers are now built per attempt
  against that attempt's slug.

- A seed response that was never observed left seedServerTime null,
  which the alignment check folded into "different second". For the
  same-second leg that already meant a retry, but the CONTROL leg would
  proceed on an unknown and claim it had proven a difference it never
  saw. An unknown seed is now its own retry.

Counterfactual re-run after the change: same-second leg fails 4/4
against a reverted query, control passes 4/4; 10/10 green with the fix.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
2026-08-13 12:18:26 -04:00
xarmian 7b46894413 fix(e2e): silence cross-actor SSE creation toasts suite-wide (BUG-2334)
The e2e suite shares one pad instance and one workspace, so items seeded
by OTHER concurrently-running specs arrive over SSE and stack
"X created: ..." info toasts bottom-right — directly over bottom-right UI
(the graph drawer's detail card), turning unrelated specs' clicks into a
race. pane-content-link-anchors:238 paid a ~40-minute rerun tail at
nearly every merge gate.

The fix is a narrowly-scoped test-surface kill switch, not a retry:

- `quietExternalToasts()` (toast store): reads a localStorage flag no
  production code ever sets; never throws whatever storage does.
- The ONE call site announcing another actor's SSE work — the external
  `item_created` toast in the workspace layout — checks it. Toasts the
  page earns with its own actions are untouched, so specs still exercise
  the real toast surface (copy-dialog's no-force-click policy keeps its
  protective value).
- The shared e2e fixture installs the flag on every context via
  `quietCrossActorToasts()`; collab-persistence's self-built contexts
  install it explicitly; account-delete's contexts never enter workspace
  routes and stay bare.
- sse-toast-quiet.spec.ts pins BOTH sides: the quiet leg anchors on the
  layout branch's own by-uuid GET (pre-attached response log — no
  arm-order race; SSE-stream response gates the create; bounded settle
  before the negative assert), and a deliberately unflagged CONTROL
  context proves the product toast still fires — the real behavior
  cannot silently regress behind the suite-wide silence.

Evidence: three consecutive full local suite runs with ZERO failures
(baseline: 1-3 interception/load flakes per run); unit tests pin the
helper's contract. Reviewed to fresh-angle CLEAN over four Codex rounds
(vacuous-anchor, arm-order, SSE-connectedness, and self-built-context
holes all found and fixed by the loop).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 15:43:42 +00:00
xarmian 84eef5dd38 test(viewer): CDP mobile touch-gesture proof + device checklist (TASK-2519)
PLAN-2392 phase 3d V3 (final task of the plan) — the mobile browser proof
for the attachment viewer's touch gestures shipped in V2 (TASK-2518). Real
touch is driven through the compositor via CDP Input.dispatchTouchEvent (a
CdpTouch helper in the e2e lib), since Playwright's touchscreen is
single-tap only.

New web/e2e/attachment-viewer-touch.spec.ts (mobile-chromium), 9 legs:
- two-finger spread/converge zoom in/out
- off-centre affine anchor oracle: a moving-midpoint translate+spread keeps
  a known image-local point under the midpoint (sub-pixel residual; a
  zoom-around-centre mutant misses by ~75px, TOL=6px)
- double-tap fit<->actual + single-image-tap-inert + backdrop-tap-close
- 2->1 lift degrade: jump-free hand-off + survivor pan arms
- '+' mid-pinch rebase (no stale-baseline snap; sampled on a tiny post-+ move)
- touchCancel all-cancel teardown + next gesture arms fresh
- letterbox touch never pans + letterbox tap closes
- image/stage touch-action:none, backdrop auto
- tap-to-load first-tap priority

CDP semantics empirically pinned (not assumed): touchStart/Move carry the
full active set; touchEnd names the ending point (pointerup#<id> observed);
touchCancel is all-or-nothing.

Emulation boundary recorded honestly in DOC-2521 (device-proof checklist):
the 2->1 survivor-pan CONTINUATION can't be expressed in CDP — synthetic
touch releases the survivor's implicit pointer-capture on the next move,
tearing the fresh pan down (a real digitiser keeps it), so the leg proves
arm+no-jump and the continuation is device-verified. Also checklisted:
gesture feel/arbitration, momentum, real touchCancel, iOS Safari (no WebKit
CI project), off-root release.

Mutation-verified (build web+go at worktree root, fresh CI server per run):
pinch handler disabled -> spread/oracle/rebase red; anchor->stage-centre ->
oracle red while spread stays green (discrimination); double-tap disabled ->
toggle red; degrade disabled -> 2->1 red; rebase disabled -> rebase red.

Codex: 3 rounds, final CLEAN (r1 flagged a stage-settle race -> fixed, and a
docs-based touchEnd objection -> refuted empirically; r2 flagged the rebase
test wasn't discriminating -> sampled on a tiny move + mutation-proved).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 13:39:05 +00:00
xarmian 9d9670e5cc feat(viewer): touch pan, pinch, double-tap + touch-action:none (TASK-2518)
3d-V2 of PLAN-2392: the attachment viewer now owns touch. `touch-action: none`
on the image and stage lets pointer handlers drive single-touch pan, two-finger
pinch, and double-tap-to-toggle; the letterbox stays a native tap-to-close (the
backdrop keeps `touch-action: auto`).

Gesture state machine (built on V1's pointer registry):
- Touch gestures arm only on a PAINTED-IMAGE hit, gated by a per-element paint
  generation (`paintedGen === loadToken`) so retry-loading is inert while the
  thumb→original upgrade stays live. The accept-gate snapshots the loader's fence
  inputs before `decoded()` mutates them.
- Pinch composes ONE candidate at the clamped final scale (anchor-zoom around the
  previous midpoint + midpoint translation), clamped once; PINCH_MIN_DIST=12 with
  the below-min HELD-scale skip and re-entry rebase.
- 1→2 promotion surrenders the pan capture (swallowing its lostpointercapture);
  2→1 degrade rebases to the surviving founder; third-and-beyond touches are
  registry-only; per-pointer pointercancel routes degrade-vs-full-clear.
- DOUBLE_TAP_MS=300 / SLOP=24, image-only, with compat-dblclick dedup; a live
  touch gesture is never seized by a mouse press, and a mouse pan keeps the looser
  bitmapPresent arm.

Owed premise inversions: the sheet e2e now asserts touch-action none; the restore
guard + test comments updated (the viewer owns touch via pointer events, but a
touchmove is still not defaultPrevented, so the origin check remains the catch).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 12:45:07 +00:00
xarmian 885871a638 test(web): browser-prove attachment lifecycle completeness (TASK-2514)
The falsifiable subset jsdom can't see for PLAN-2392 phase 3c-iii — one
Playwright leg per lifecycle mechanism the U1-U3 chain built, plus the fix
for the U3 count test that was authored but never run.

- Navigation-step (U3): a 3-image set whose viewer order is DERIVED at
  runtime (created_at-DESC ties are the DB's, not the upload order); the
  middle-navigated "arrival" is deleted via a SEPARATE API context so the
  process-local bus never tombstones it, its metadata is primed with a
  cacheable 200 in the PAGE context, and arrowing onto it after a reopen
  forces a no-store HEAD that 404s DESPITE the primed 200 (armed
  waitForResponse, causally the arrow's probe) → tombstone-advance to a
  distinct survivor.
- Restore-revalidate (U2): on an ISOLATED workspace (the shared suite's SSE
  stream starves the delta-sync cursor), archive via per-item event then
  RESTORE via the BULK endpoint (items_bulk_updated, no item_id) — proving
  the prop-driven strip revalidation covers what a per-item SSE subscription
  would miss. Asserts no attachments.list on archive, and a one-shot route
  HOLDS the restore's revalidation list in flight to prove the tiles never
  blank DURING the fetch, not just after.
- Timeline (U1): a strip-UI delete (so announceAttachmentDeleted runs on the
  process-local bus) reconciles a comment thumbnail img→missing live, with
  the document + timeline element stamped to prove no reload or remount.

Also fixes attachment-surface-chrome.spec.ts's U3 count barrier: a bodyless
HEAD is reported as net::ERR_ABORTED after its headers arrive, so it fires
requestfailed, never requestfinished — the completion barrier now keys on
the response.

New e2e/lib/attachment-viewer.ts helpers: createWorkspace, createDoc,
archiveItem, restoreItem, bulkItems (workspace-slug-aware), STRIP_DELETE.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 04:38:37 +00:00
xarmian c5190d6a96 feat(web): revalidate attachment metadata per navigation step (TASK-2512)
Generalize T6's per-open forced-probe (`forcedNonce`) to per-(openNonce,
attachment): `forcedFor: { nonce, ids: Set }`. The opened entry AND every entry
navigated to now gets exactly one automatic `no-store` revalidation, while
arrowing BACK to an already-probed entry within the same open takes the fast
path. A cross-tab deletion of a sibling is no longer invisible when arrowing to
it. A reopen mints a fresh nonce, so the set resets and every entry re-probes.

Two semantics pin the accounting:

- COMPLETION, not dispatch (round-2 P1): a pair is recorded only when its forced
  probe resolves non-stale. A probe discarded stale (arrow away before it
  resolves) leaves the pair unseen, so arrow-back re-probes rather than painting
  a maybe-deleted entry live off the seed.
- AUTOMATIC only (round-4 P2): a Retry-/restore-driven forced probe (the reload
  path) never records the pair, keeping the two mechanisms independent — an
  arrow-back after a Retry still gets its one automatic probe.

The mark is a plain-object write in the async continuation, guarded by the
existing `req.stale()` check and keyed to the pair the run dispatched for, so it
joins no tracked scope and cannot self-invalidate the effect.

Tests: this task owns the T6-era expectations its behavior change INVERTS.
- surfaceMetadata.svelte.test.ts: the two "navigation keeps the nonce → no
  additional forced probe" tests now assert navigation to a fresh sibling forces
  a second no-store revalidation (complete OR incomplete seed); added an
  arrow-back-is-fast-path test and two new-behavior tests (delayed probe →
  stale-discarded → re-probes; completed Retry does not record → arrow-back still
  auto-probes), both mutation-verified to fail on the naive regressions.
- AttachmentSurfaceHost.svelte.test.ts: the arrow test inverts to "arrowing to a
  fresh entry forces one no-store probe of the arrival; arrowing back does not".
- Lightbox.svelte.test.ts: corrected two tombstone-advance comments that claimed
  advanced-to entries use the plain fetch (they now force per U3).
- attachment-surface-chrome.spec.ts (e2e, not runnable in this worktree): the
  no-store counting test inverts — arrowing to a fresh sibling now forces one
  HEAD of the arrival; final counts a:2,b:1. Kept to race-free claims only.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 02:53:43 +00:00
xarmian bbb5a69219 fix(attachments): dock-clear the viewer nav on the mobile sheet (PLAN-2392 3c-ii)
The prev/next arrows were direct children of the fixed backdrop, centred
`top: 50%` against the FULL viewport. The T5 phone sheet shortens the stage
and docks meta+toolbar at the bottom, but the arrows had no sheet-scoped
anchor, so on short/landscape phones they landed in or over the dock —
obscured, or stealing the dock's taps.

Move the two `.lightbox-nav` buttons INSIDE `.lightbox-stage`. On desktop the
stage is `position: static`, so their `position: absolute` still resolves
against the fixed backdrop — byte-identical full-viewport centring. In the
sheet the stage is `position: relative`, so `top: 50%` re-anchors to the
shortened stage box and the arrows clear the dock with no magic-number dock
height. Add `pointer-events: auto` to `.lightbox-nav` (the stage is
`pointer-events: none`); on desktop that was already the inherited value.

Nav now trails the toolbar in DOM order (Close, toolbar, Previous, Next);
accessible-name addressing keeps the trap tests green — adjusted the two
order-naming assertions in the modal-contract spec. Adds a 720x400 landscape
e2e leg asserting the arrows centre on the stage (not the viewport), sit clear
of the dock, and stay clickable; the pre-fix DOM fails the stage-centre
assertion by a dock-half.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 22:39:32 +00:00
xarmian 9c9107176c test(e2e): reconcile attachment e2e with the converged surface (TASK-2493)
PLAN-2392 phase 3c-ii T7 — the e2e half of the convergence (one host,
one Lightbox for ANY attachment; the options-panel + image-viewer
channels retired).

Falsified + rewritten (the convergence changed the premise, so these
were rewritten to assert the new behaviour, not deleted):
- strip file-tile / editor file-chip open the role=dialog surface
  (no-bytes fallback arm), not a role=menu options panel
- modal two-stacked-viewers -> the SUPERSEDE invariant (one host mounts
  at most one Lightbox by construction)
- owner-4 BottomSheet source moved from the retired file-panel to the
  surviving strip delete-confirm menu
- parity/two-host exact dialog-name -> anchored RegExp (T2b grew the
  accessible name to "name, type · size"); enforced in the hostile-name leg
- zoom thumb->original timeline, switch-safety, and mobile deferred-load
  counts: filter to GET (the T6 always-revalidate-on-open no-store HEAD
  hits the same variant-less URL and polluted the counts)

New legs: PDF/ZIP fallback integration (Open for PDF, none for ZIP);
T6 no-store HEAD count (one per open, none on arrow, one on reopen);
DR-14 archived-parent probe-gate + archive-while-open close; dual-host
peeked addressing + un-peek; Pixel-7 sheet geometry / dock contiguity /
backdrop-vs-chrome dismissal / shortened-stage zoom / file route /
overlay-centring / DR-18 label reveal / native-pinch touch-action /
forced-colors Canvas plate; desktop-unchanged contrast.

Each of the four load-bearing behaviours was MUTATION-verified (break in
source, rebuild the worktree ./pad, confirm the targeted leg FAILS,
restore, confirm green): fallback admission, host event addressing,
archive-close transition, and the T6 forced no-store probe.

Codex-reviewed to CLEAN over five rounds. New selectors live in
web/e2e/lib/attachment-viewer.ts, addressed by class or accessible name
(never a bare [role="dialog"]); assertions are item-scoped / by-id /
by-anchored-name to avoid the BUG-2504 unscoped-list pagination trap.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian e08901df28 fix(e2e): resolve imported attachment via content reference, not list page 1 (BUG-2504) (#1075)
The round-trip spec's final assertion fished the imported workspace's
unscoped attachment list, which defaults to limit 50 / created_at_desc.
Once sibling specs (PLAN-2392 browser proofs) grew the shared e2e
workspace past one page, the seeded logo — old in the sort — fell off
page 1 and the find() failed, holding main's CI red since 2026-08-06.
The trigger window contained only CI-action bumps; the race was latent
and runner-timing shifts made it deterministic.

Resolve the rewritten pad-attachment: UUID straight from the imported
item's content instead — the contract the UI actually follows — and
assert filename via Content-Disposition plus a byte-for-byte download
match. Immune to suite growth by construction.

Claude-Session: https://claude.ai/code/session_01VxyZv1g6W6rGx7nuaGcH3i
2026-08-10 13:38:37 -04:00
xarmian a593407a21 test(attachments): browser proof for the 3c-i surface chrome (TASK-2484)
DR-9's rule — the a11y and interaction work of the A-E chain is verified in a
browser or it is not verified. Desktop-chromium legs for the viewer's toolbar,
metadata header, delete flow, permission gate and gesture seams (the sheet
layout has no mobile e2e until 3c-ii).

New spec web/e2e/attachment-surface-chrome.spec.ts:
- Toolbar renders on all THREE origins (strip, timeline, body NodeView), with
  Open/Download as real anchors carrying the EXACT canonical variant-less URL
  (^/api/v1/workspaces/{ws}/attachments/{id}$) and the exact download filename.
- The permission gate: a peeked side withholds the delete affordance
  (mutationsEnabled=false reaches it) and the active side's viewer toolbar
  offers Delete.
- The delete flow: toolbar Delete → drill-down reached BY KEYBOARD with the
  roving tabindex asserted (0/-1 ↔ -1/0), confirmed with Enter, the viewer
  ADVANCES to the survivor (not the retired C1 close), and the deleted strip
  tile disappears (bus reconciliation).
- The metadata header: name/type/size visible, a 180-char filename clipped with
  a resolved text-overflow:ellipsis and the full value in title (DR-13), and the
  inert-label contract proven by a DRAG on the header that does not pan a
  zoomed BIG_PNG image nor dismiss the viewer.
- The gesture seams: a wheel over the toolbar zooms neither the image nor the
  inert page behind it, with a control wheel over the stage that DOES zoom.

Every leg was mutation-checked against this worktree's built binary (revert the
impl line, rebuild, confirm the test fails, restore) — wheel exclusion,
peek-permission (mutationGate canEdit && !peeking), header name, delete advance,
toolbar-render, and the header pointer-exclusion. The wheel and header
mutation-checks each surfaced a false-pass that was fixed (a zoom-out clamped to
fit; a too-small image with no pan bound).

The FALLBACK arm + no-bytes invariant is a documented test.fixme: it is not
reachable through the real producers (they snapshot the viewer set at open and
filter unsafe MIME before it reaches the viewer), so it is jsdom-proven
(TASK-2476, via direct prop mutation). The peeked-side no-Delete VIEWER is
similarly jsdom-proven (TASK-2474): the content click that opens a viewer
re-activates (un-peeks) that side under the invisible-freeze model.

Two existing modal-spec trap tests were updated: the toolbar added focusable
controls, so the "last control" is derived (focusViewerLastControl) rather than
named, and the wrap is still asserted by name at both edges.

https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 18:39:33 +00:00
xarmian 872612360a test(attachments): browser proof for viewer zoom — desktop and mobile (TASK-2461)
Phase 3b's final task: the browser-level proof of the attachment viewer's
zoom/pan/loading behaviour that jsdom structurally cannot give (no layout,
no CSS, no gestures). New Playwright spec e2e/attachment-viewer-zoom.spec.ts
runs on desktop-chromium and the Pixel 7 (mobile-chromium) project, with
shared fixtures/helpers added to e2e/lib/attachment-viewer.ts.

Desktop legs prove the RENDERED transform moves under wheel / ctrl-wheel /
keyboard / double-click; the anchored point stays under the cursor; pan
clamps in two legs (an in-bounds drag moves by the delta, an over-drag
stops at the edge with no further movement); a press-drag-return-to-start
over the backdrop does NOT dismiss while a plain backdrop click does; a
click on blank stage space inside the stage box but outside the image
dismisses (the letterbox is pointer-transparent); close/nav stay hit-
testable and Tab still cycles at maximum zoom; enlarging the window clamps
the stranded scale down to the new maximum (still zoomed, not reset);
reduced-motion suppresses the animation while normal mode keeps it; and
forced-colors keeps the image boundary visible. Loading legs prove the
thumb->original swap (the thumb response finishes before the original is
requested) and that a rapid A->B->A with a slow original leaves the live
image correct (the switch-safety end-state; Chromium aborts detached img
loads, so the detached-late-error fence itself stays unit-covered). The
mobile leg proves the DR-5b deferred cell issues no automatic request until
a real tap, then loads exactly one original.

Every assertion is mutation-checked against the binary the Playwright
webServer launches: each test fails when its implementation line is
reverted. Selectors are class-qualified or by accessible name, never a bare
[role=dialog]. Controls are addressed by accessible name; shared constants
live in the lib.

Also lands the DR-4 forced-colors CSS in Lightbox.svelte (deferred in the
zoom tasks): a system-colour border on the image boundary (its box-shadow
is stripped under forced-colors) plus explicit ButtonText borders on the
controls — the contract the browser proof verifies.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 06:27:25 +00:00
xarmian df741172fe fix(a11y): keep Escape consumption event-scoped so one press closes one layer (BUG-2441, TASK-2448)
`isBlockedByModal()` answers "is a viewer lease held RIGHT NOW". `Lightbox`'s
escape-stack handler calls `onClose()` synchronously, and Svelte flushes the
teardown — hence `lease.release()` — inside that call. Every `window` keydown
listener later in the SAME dispatch therefore asked against an empty stack, was
told "nothing is in front of you", and closed a second layer: one Escape closed
the viewer AND the `DockedSheet` / `BottomSheet` underneath it. The query was
right; the moment it was read was not.

Consumption is now recorded per EVENT. The viewer marks the dispatch it
consumed (`noteEscapeConsumedByViewer`) before closing, and `isBlockedByModal`
takes an optional event: a marked one blocks every later owner outright,
whatever the lease says by then. `runTopEscape(event)` forwards the driving
event to handlers so the viewer has something to mark; the stack itself never
reads it.

Keyed on the event object, NOT on `defaultPrevented` — that flag says only
"somebody handled this key", is set by controls that are not viewers, and
honouring it would change sheet behaviour with no viewer present. This marker
can only ever be set by a frontmost viewer, so on an empty lease stack it is
unreachable by construction.

DELIBERATE, NAMED BEHAVIOUR CHANGE — the FOURTH named parity exception of
PLAN-2392 phase 3a, alongside the three already recorded. `DockedSheet`,
`BottomSheet` and the `TopBar` overflow menu now decline an Escape a viewer has
already consumed. TASK-2430 shipped `DockedSheet` declining an already-
`defaultPrevented` Escape unannounced and it was reverted; this is approximately
that change made deliberately, with a stated reason, a narrower trigger and
tests. `TopBar` is not known to be broken today — its listener happens to run
before the route driver — but that is mount-order luck, not a guarantee, so it
is closed too.

EMPTY-STACK PARITY, per owner: with no viewer, the marker cannot exist, so each
touched call site reduces to exactly its previous expression. Asserted rather
than argued — `DockedSheet` and `BottomSheet` each gain an unmarked-Escape
regression beside the new blocked case, `TopBar`'s existing owner-5 e2e covers
both directions, and a `viewerBackdrop` unit test states the equivalence
directly (`isBlockedByModal(o, unmarked) === isBlockedByModal(o)`). The reverted
2430 `defaultPrevented` regression test still passes untouched.

The two `test.fail()` cases pinning BUG-2441 are now real assertions, each
extended with a second press proving the sheet keeps its own Escape rather than
going permanently deaf.

MUTATION-VERIFIED, both halves (TASK-2436's precedent):
  • drop the viewer's mark → owners 3 and 4 fail: "the sheet is a LOWER layer
    and must survive the press / element(s) not found", plus the new Lightbox
    jsdom case ("expected spy to not be called at all, but actually been
    called 1 times").
  • drop the sheets' event argument → the same two e2e cases fail identically.
  • drop the driver's `runTopEscape(e)` → the wiring contract fails
    ("expected … to match /runTopEscape\s*\(\s*e\s*\)/").

Gates: npm run check 0 errors; npm run test 1090 passed; the three viewer e2e
specs 32 passed.
2026-08-06 17:22:28 +00:00
xarmian 228f99318b test(e2e): prove the viewer's modal contract in a browser (TASK-2436)
Phase 3a deleted a native `<dialog>` that `showModal()` was giving five
guarantees for free — top-layer stacking, background inertness, a focus trap,
focus restore and Escape — and hand-wrote each one. jsdom's `<dialog>` polyfill
(`src/test/setup-jsdom.ts`) only toggles attributes, so the phase's unit suites
cannot see ANY of those five: no inertness, no top layer, no `:modal`, no real
Tab traversal, no stacking. DR-9 says this is verified in a real browser or it
is not verified.

Three specs, 32 tests, each written against "what mutation would this catch
that a jsdom-equivalent implementation would survive":

  attachment-viewer-modal.spec.ts — portal + MEASURED viewport geometry (a
  `transform`/`contain` ancestor changes the rect, not the declaration); focus
  entry; background inertness proven by injecting a focusable probe into every
  body child AND by the REAL top-bar control, which can only go inert by
  cascade; focus restore asserted as an ORDERING (the invoker is verified
  UNFOCUSABLE while the viewer is up, so a restore-before-release could not
  pass) AND on its DECLINE path, with a detached invoker — the ordinary case,
  since the NodeView that opens the viewer is re-rendered on any document
  change; the focus trap in BOTH directions, including the backward-wrap branch
  (`nextTrapTarget` returns `last` only for Shift+first) and the single-control
  viewer where first === last; `showModal()` vs `show()` vs a dialog mounted
  closed and shown later, plus a native modal opened OVER the viewer winning
  both Escape and Tab outright; paint order hit-tested against a 99999
  body-portaled rival, with a raised-z-index control so the measurement is
  provably sensitive to stacking (Chromium excludes inert subtrees from hit
  testing, which would otherwise make it vacuous); Escape through BOTH real
  route guards, asserting which layer closed; two stacked viewers; and the
  mobile pane integration, where the pane's nested `inert` writes and the
  backdrop's body-child writes are shown to be disjoint at every transition.

  attachment-viewer-owners.spec.ts — all seven TASK-2430 owners, each with a
  viewer-frontmost case AND an empty-stack regression: the six root shortcuts,
  the collection route's navigation half, DockedSheet, BottomSheet, the TopBar
  overflow menu, the sidebar edge swipe (including a gesture that STRADDLES the
  viewer opening) and the co-mounted item graph.

  attachment-viewer-parity.spec.ts — the finite parity matrix, four producers ×
  {open, ←/→, Escape, backdrop click, close}; Enter/Space activation of inline
  images including explicit `repeat: true` keydowns; Cmd/Ctrl+Enter still being
  the comment editor's SUBMIT; hostile/long/bidi accessible names and RTL
  geometry; the host lifecycle, driven through CLIENT-SIDE navigation with the
  document verifiably still mounted (a `page.goto()` version would prove only
  that unloading a document removes its DOM); and two-host isolation.

TWO KNOWN DEFECTS ARE RECORDED AS `test.fail()`, not papered over — BUG-2441.
One Escape over a DockedSheet or a BottomSheet closes BOTH that sheet and the
viewer. The sheets' `isBlockedByModal()` guards are correct; they are READ too
late. Both they and the route's escape driver are `window` keydown listeners,
and Svelte flushes the viewer's teardown synchronously inside the driver's
handler, so a sheet listener running later in the SAME dispatch sees an
already-empty lease stack. Invisible to the unit suites (one component's
handler, nothing releasing a lease mid-dispatch) and invisible to a
click-driven test — closing the same viewer with its Close button leaves the
sheet open, which is how it was isolated. The annotations are applied AFTER
setup, so a login/seed/navigation failure cannot hide behind them. The tests
assert the CONTRACT, so the day it is fixed they go red and the annotations
must come off. The TopBar overflow menu, checked the same way, is unaffected.

Documented gaps, stated rather than papered over: the paint-order rival is a
synthetic overlay at the picker's declared z-index (the real picker cannot be
co-present — opening the viewer by pointer dismisses it) and must be de-inerted
to be hit-testable; `expectBackgroundInert`'s floor is one behaviourally-proven
background child; the pane test's inert-set comparison identifies elements by
tag plus first class; and gestures under a frontmost viewer are dispatched
rather than delivered, since a real wheel or touch cannot reach a covered
element (the graph's baseline leg does use real input).

The shared fixture builds a real 200x150 PNG rather than reusing the 1x1 the
older attachment specs share: that one has a bad IDAT checksum, so thumbnail
decoding skips and the rendered `<img>` has no box — unclickable, and "not
visible" to Playwright. It also has to out-size the editor's image toolbar,
which is absolutely positioned over a small image's whole area.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-06 17:02:18 +00:00
xarmian 7cfe50d842 feat(a11y): defer global key and gesture owners to a frontmost viewer (TASK-2430)
Global keyboard/gesture owners now consult the shared arbitration helper
(`isBlockedByModal`) instead of acting unconditionally, so the
native-`dialog:modal` branch is enforced everywhere rather than only in the
two route files TASK-2429 rewired.

This is modal-contract work, not parity work: route, graph, pane, sidebar and
sheet behaviour deliberately changes while a viewer is frontmost. With NO
viewer and no native modal the helper returns false, so EMPTY-LEASE BEHAVIOUR
IS UNCHANGED FOR EVERY OWNER — each ships an empty-stack regression test, and
forcing any owner's guard to decline unconditionally fails one (verified per
owner). Every guard is also mutation-verified to kill at least one test in the
other direction; there is no guard left that a test cannot fail on.

Two earlier revisions of this commit broke that promise and were REVERTED:
DockedSheet declining an already-`defaultPrevented` Escape, and the overflow
menu's arrow-nav being revived past `svelte-dnd-action`'s role rewrite. Both
fire with no viewer present, both are defensible on their own merits, and both
belong in their own item rather than arriving unannounced inside an attachments
phase. The one remaining empty-lease change is named and intended: `?` no
longer closes the Keyboard Shortcuts modal from inside itself, which falls out
of the native-dialog branch this task exists to enforce (Escape and its close
button still dismiss it).

The seven owners:
  1. root app-shell shortcuts (+layout) — were entirely unguarded
  2. both route keydown handlers (see the asymmetry below)
  3. DockedSheet — an unregistered role="dialog" Escape owner
  4. BottomSheet — front layer wins over the sheet-only frontmost check
  5. TopBar overflow menu — Escape + Up/Down
  6. Sidebar — mobile edge-open swipe and the swipe-to-close
  7. ItemGraph — wheel zoom and pan; it co-mounts with the viewer

ESCAPE IS NOT ARBITRATED ON THE TWO PANE ROUTES, deliberately. Those handlers
are the only code that runs `escapeStack`, and the VIEWER's Escape lives there
— an arbitration bail above the dispatch would return first and leave a
frontmost viewer undismissable by keyboard, reintroducing exactly the dead key
TASK-2429 fixed. What DID need arbitrating is the collection route's NAVIGATION
half (j/k, arrows, h/l, Enter, Tab), which would otherwise keep re-targeting
the list under the viewer — so the guard sits below the Escape dispatch and
above the nav switch, and both bounds are asserted. The item route, being
Escape-only, gains no arbitration guard at all (its existing `defaultPrevented`
/ text-entry / `hasForeignEscapeOwner` guards are untouched). Hoisting the
guard, dropping it, and adding one to the item route are all mutation-verified
to fail a test.

`hasForeignEscapeOwner()`'s ARIA branch becomes LEASE-AWARE, because 3b changes
its premise. It used to be right that a sheet open beneath a viewer still owned
Escape — the sheets acted unconditionally. Now they stand down, so reporting
one would leave Escape with NO owner: driver returns, sheet declines, viewer's
stack never runs. The branch now counts only sheets NOT behind the frontmost
viewer, by CONTAINMENT rather than a blanket "a lease exists": a sheet nested
INSIDE the viewer is in front of its content and does still own its Escape.
The native branch is checked first and wins outright, on both the
`dialog:modal` path and the `dialog[open]` fallback: nothing in the app guards
a native `<dialog>`, so unlike a sheet it never stood down and does still own
Escape. Applying the containment rule to it as well was tried and reverted —
the fallback cannot tell a modal from a non-modal dialog, so letting the lease
out-rank it would fire the browser's native `cancel` AND run the stack, closing
two layers on one press. The residual asymmetry that leaves (a NON-modal
`<dialog open>` beside a viewer, on an engine without `:modal`) is documented
at the branch and is unreachable here twice over: `Modal.svelte` is the only
`<dialog>` in the tree and only ever calls `showModal()`, and every engine that
ships `<dialog>` ships `:modal`.

Plus two more global Escape owners found by review sweep: the workspace graph
route and the console shell. Neither can host a viewer and neither drives the
escape stack, but the root layout mounts native dialogs on both, so one press
would cancel the dialog AND mutate the layer underneath.

Captured gestures that straddle the viewer opening are gated at START and on
the captured move/end: the graph has no `lostpointercapture` handler, so its
pan is torn down (capture released) rather than merely skipped; the pane
divider ends its resize; the sheet and sidebar swipes are abandoned. The start
gates are separately load-bearing — a gesture begun under a viewer must not
come alive when the viewer closes — and are tested as such.

Owner arguments are the ACTING SURFACE (a bound element, `e.currentTarget`, or
`null` for the app shell), never `event.target`. The four WINDOW-level call
sites — +layout, TopBar, DockedSheet, BottomSheet — each have a test that
dispatches from inside the viewer, which is the case that distinguishes the two
choices; the element-bound listeners (PaneHost's divider, Sidebar's aside,
ItemGraph's viewport) cannot receive an event originating in the viewer at all,
so there is nothing to distinguish there.

Deliberately NOT guarded: pure pointer-dismissers (clickOutside, the pickers,
board lanes, and TopBar's outside-click), which only tear down lower UI.

DEFERRED, not covered here: `svelte-dnd-action`'s global drag handlers (nine
call sites) and the editor's block-drag action own gestures whose finalize can
persist a reorder if a viewer opens mid-drag. Gating them needs a reactive
lease signal rather than a call-site guard, which is a materially larger change
than this task's contract — flagged for a follow-up item.

ItemGraph's pointerup path carries NO gate: the obvious symmetry with the move
gate is unfalsifiable — teardown is identical either way, so no test can fail on
its removal — and an unkillable guard reads as coverage without being any. The
straddle is covered by the move gate, which releases the pointer capture. The
one sequence neither gate can see (a capture-less press whose release RETARGETS
to the portaled viewer, leaving `maybeDrag` latched) is pre-existing and already
mitigated by the `buttons & 1` abort in `onPointerMove`; a test now pins that
mitigation so it cannot be removed silently.

Also in this commit:
  - e2e: target the create-workspace dialog by accessible name, not a bare
    `dialog` role
  - test infra: `$app/navigation` mock + a localStorage shim for the jsdom
    project, without which Sidebar/TopBar/PaneHost/+layout could not be
    mounted at all. Both Storages are cleared before every TEST (not per
    setup-file load) so the shim is deterministic under any pool config; the
    trade-off — in-memory stand-ins cannot reproduce real Storage failures — is
    documented at the shim. NOTE: `svelte-dnd-action`'s role rewrite
    (`menu`/`menuitem` → `list`/`listitem`) leaves TopBar's roving-focus query
    matching nothing in the browser. Pre-existing, left as-is, documented at
    both the query and its test.

The native top-layer leg of the precedence rule is not asserted against a real
engine (jsdom has no top layer and throws on `:modal`; the suite emulates it);
end-to-end proof belongs to TASK-2436's Playwright suite.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-06 16:50:10 +00:00
xarmian a40049c214 fix(attachments): meet criteria 1, 3 and 5 as written
Final round, scoped to this phase's six acceptance criteria rather than
to whatever the diff suggested — the previous rounds had started finding
issues in adjacent surfaces, which is the signal that the core had
converged and the review was expanding.

Three of the six were not actually met:

3. Download returned `undefined` for a nameless row, which drops the
   attribute entirely and turns Download back into a navigation. The
   server sends an inline disposition for most types, so the file would
   have OPENED instead of saving — the precise regression this action
   exists to prevent, reachable whenever a chip's metadata is partial.
   The attribute is now always present; empty just lets the browser name
   the file.

5. "A peeked pane offers no delete" was implemented as a visible,
   disabled Delete row. The strip hides its delete control outright in
   the same state, so one object was offering two different affordances
   for one permission depending on which surface you met it through.
   Delete is absent now, with `enabled` and `run` still gating behind it.

   That change surfaced a conflation in the panel: its action context
   ANDed permission with `missing`, so once the descriptor used it to
   decide EXISTENCE, a gone row lost Delete while Open and Download
   stayed present-and-disabled beside it. Permission and reachability are
   separate questions again — the render site already disables every
   action while missing.

1. The editor-chip half of "the same panel wherever you meet an
   attachment" had no end-to-end coverage: the chip's tests mock the bus
   and the host's inject events directly, so nothing exercised a real
   NodeView reaching a real host. Covered now by a browser test that
   drops a text file and clicks the resulting chip.

make check exit 0, 745 unit tests, 6/6 e2e locally.
2026-08-04 21:26:37 +00:00
xarmian b253a2be6f test(attachments): cover the wiring the unit suites structurally cannot
Final review round 3, on the test suite as a deliverable.

The panel had no producer-to-host test: the strip's tests mock the event
bus, the panel host's tests emit on it directly, and between them a
broken hostToken thread through ItemDetail would have passed everything.
Verified by breaking that thread — the new browser test fails, the whole
unit suite stays green.

It is also the only place DR-12's "activates exactly once per key press"
can be demonstrated at all: jsdom does not synthesise a button's
activation click, so the unit test could only ever prove the narrower
"no hand-rolled handler races the UA click". That test is renamed to
claim exactly that, with a pointer to where the real one lives.

Also folds the workspace into the host-address reader. It was captured
once in the Tiptap options while the URL builder stayed live, so a
mounted chip surviving a pane workspace switch would probe under the
PREVIOUS workspace's key — a cross-workspace answer, cached under the
wrong key. Same staleness class as the item id, one axis over. This
incidentally makes the image extension's `address` option load-bearing
rather than the dead plumbing the review flagged: its probes read the
live workspace through it now.

isAddressable deliberately takes only the two ROUTING fields — the
workspace rides along for cache keying and says nothing about whether an
event can find its host.
2026-08-04 19:50:22 +00:00
xarmian c4189b1dc6 fix(attachments): one in-app delete confirmation, everywhere (TASK-2425)
The strip's hover `×` raised a browser-native `window.confirm` while the
options panel — and the rest of the item UI — drilled down to an in-app
sub-view. Two confirmation styles for one object is exactly what DR-18
exists to prevent, and the settings Storage tab's row Delete was on a
native `confirm()` too.

All three now render one shared `AttachmentDeleteConfirm`: prompt as
`role="presentation"` carrying an id, `aria-describedby` back-reference
from the destructive row, Cancel FIRST so the focus handoff can never
land Enter on Delete, destructive row last. It renders rows only — each
surface supplies its own `Menu`, so ESC ordering, outside-click, portal
placement, focus return and the mobile sheet swap stay the app's
existing behaviours rather than a second implementation.

Both warning arms carry through verbatim, from one shared builder: the
referenced arm and the hedged one, which stays hedged because the check
can only ever speak for the item it has. The Storage tab keeps its own
wording (the GC grace period) — a reference check has no meaning in a
workspace-wide list — but shares the shape.

The delete REQUEST paths are untouched: same entry paint fence, same
`viewFence.begin()`, same optimistic removal and single-row rollback,
same 404-is-authoritative arm, same `announceAttachmentDeleted`. One
addition each: `window.confirm` blocked the thread, so the entry fence
was still true by definition when it returned — an in-app confirmation
does not, so the fence is re-checked where the request is actually sent,
and an open confirmation is abandoned when the view changes under it or
another surface deletes the row.

Also fixes an unhandled rejection the suite surfaced: `Menu` places
itself in a `tick().then()` that can run after its block is torn down,
so every prop expression reading the pending state needs `?.`.

Tests: the ~18 strip tests (and 4 Storage tab tests) that spied on
`window.confirm` now drive the real rows; every message-arm, fence and
rollback assertion is preserved. New coverage for the confirmation's
shape, Cancel's focus return, the confirm-time fence, and abandonment
on switch / external delete. The e2e strip spec drops its `dialog`
handler and pins the 24×24 target size in a real browser.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-04 13:48:05 +00:00
xarmian cccb9d5858 fix(attachments): bound strip buffers and surface load failures (TASK-2418)
MAX_FETCH was documented as a cap but only ever reached the fetch `limit`:
the upload path prepended unconditionally and `pendingUploads` was itself
unbounded, so a long paste session grew the in-memory list — and the
lightbox set it feeds — without limit. Every growth path now runs through
`capped()`: the load-time merge, the upload event, the pending buffer that
rides on top of the merge, and the failed-delete rollback (PLAN-2392
DR-11).

A failed list fetch used to render as "no attachments", making a broken
strip and an empty one indistinguishable. It now shows a compact
"Couldn't load attachments · Retry", and Retry invalidates the shared
per-attachment HEAD-metadata cache before refetching — that cache latches
`null` on failure for the page lifetime, so a naive retry would replay the
cached failure on every surface that probed during the same outage
(DR-10). Retry also keeps what the failure preserved (optimistic uploads,
deletion tombstones) rather than clearing like an item switch. A delayed
loading row makes loading, empty and failed three distinguishable states
without flashing a block above the editor on the common un-attached item;
empty still renders no section at all (DR-18).

The header shows the true count, `50+` once rows exist past the bound, and
the overflow continuation is now item-scoped: "View all (N)" links to
`?attachment_item=<id>#storage`, the settings route passes it through and
owns clearing it, and StorageTab seeds its existing `item_id` filter from
it with a visible, clearable scope chip plus its own loading/error states.
The count is tracked as a delta beyond the strip, anchored on the server's
`total` and corrected for rows the page reported as deleted and for
uploads the page predates — so local deletes and uploads keep it honest.
Two residuals are deliberate and commented at the call site: a deletion of
a row PAST the bound can't be attributed (the bus is workspace-wide) and
may overstate by one until the next load, and uploads shed by the pending
buffer during a single in-flight request are uncounted because counting
them double-counts the ordinary case.

The test asserting a failed fetch shows no visible error is deliberately
falsified and replaced.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-03 03:00:47 +00:00
xarmian a99e71afe4 feat(web): refresh the attachment strip on upload (TASK-2385)
A file dropped or pasted into the editor now appears in the item
attachment strip immediately, instead of waiting for the next load of
the item (PLAN-2382 phase 3).

The task specified threading an onAttachmentUploaded callback down
through both <Editor> branches. Implemented on the attachment event bus
instead: TASK-2384 already introduced one for deletions, the strip
already subscribes to it, and reusing it avoids prop-drilling a second
channel through a component that has no other reason to know about the
strip. The deletion module is renamed $lib/attachments/events.ts to
cover both directions.

The upload closure captures the item id at upload START -- the promise
outlives an A->B switch even though <Editor> is keyed on item.id, and
AttachmentUploadResult carries no item_id, so that is the only point
where the association is known. Uploads without item context are not
announced: the server leaves item_id NULL for those, so an optimistic
tile would vanish on refresh.

The strip's internal row type is narrowed to what a tile renders. The
upload response has no storage_key / content_hash / created_at, and
fabricating them to satisfy AttachmentListItem would be worse than not
modelling columns nothing displays.

Also adds the browser-level coverage this plan was missing. The
component suite mounts the strip directly, so it passes even if the
ItemDetail mount is deleted or mis-wired; e2e/item-attachment-strip.spec.ts
pins in a real browser: the strip is mounted and shows only the current
item across an A->B switch, a dropped file appears with no refetch,
delete removes the tile and degrades the inline image to the missing
placeholder, the delete control genuinely takes keyboard focus (jsdom
applies no scoped CSS, so a regression to visibility:hidden is invisible
there), and a peeking master shows tiles with NO delete control. That
last one was mutation-verified: passing canEdit instead of
mutationsEnabled fails it.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 14:00:17 +00:00
xarmian e97a13eb73 fix(web): untrack the copy dialog's open/close reset (BUG-2379)
Reopening the copy dialog after changing the destination workspace
wedged Svelte's effect scheduler. The dialog silently failed to appear
and every other control on the item pane died with it — the ⋯ menu
stopped opening, the split view could not be closed, the selected item
could not be changed. No console error, because a production build
reports none.

`$effect.pre` called `resetForOpen()` inside its tracked scope.
`resetForOpen` writes `destWs = sourceWsSlug` and then reads `destWs`
back to start the collection load, so the effect depended on a value it
had just written: the write invalidated the effect performing it, the
flush aborted, and the aborted flush stranded unrelated reactivity
across the pane. That is the CONVE-1688 hazard, and the comment
directly above the effect asserted the opposite — that `open` was its
only dependency.

It could not bite on the first open. `destWs` already equals
`sourceWsSlug` there, so the reset is a no-op write and nothing
invalidates. It needs a real destination change, a close, and a reopen.

Both branches now run inside `untrack`, so `open` really is the only
dependency.

Why the review missed it: all ten e2e cases opened the dialog exactly
once. Thirteen plan-review rounds, per-task Codex loops and four
full-diff rounds all reasoned about the effect from its comment, which
claimed the property that was untrue. Adds the reopen case, which
asserts the pane is still alive afterwards rather than only that the
dialog returned — mutation-verified: it fails with the untrack removed.
2026-08-01 01:11:26 +00:00
xarmian fbfbfcfe34 test(web): e2e coverage for the copy/move dialog (TASK-2355) 2026-07-31 19:53:16 +00:00
xarmian a6b48c75b9 fix(web): lane inline-create opens the split pane on desktop, stays put on mobile (IDEA-2298) (#1036)
The board lane `+` opens a Trello-style draft card (TASK-1676) whose Enter
handler hardcoded a full-page `goto(.../{item}?new=1)`. TASK-1676 predates the
split pane (PLAN-2105), so nothing revisited that destination, leaving the one
create gesture that already knows its title as the only card-open path that
bypasses the pane:

- Clicking an EXISTING card opens the split pane (`onItemOpen` → `?item=`);
  creating one navigated the whole page away from the board.
- `?new=1` exists to drop you into the title editor of a fresh "Untitled" item
  (`createNewItem`). On this path the title was just typed, so it re-opened the
  title editor with that title select-alled.
- There was no viewport branching at all, so mobile — where the lane `+` is
  fully present — got ejected off the board too, making a second add a Back
  navigation.

`quickCreateInColumn`'s third param becomes `reveal` (caller INTENT) rather than
`navigate` (a destination): the local-index upsert is now unconditional so the
card always lands in its lane, and revealing means `openItemPane(item)` on
desktop and nothing on mobile. The page owns what revealing means, so BoardView
stays unaware the pane exists. The composer closes on submit on every viewport —
on desktop the pane takes focus, so keeping it open for rapid entry would fight
it. Revisit if feedback asks for mobile rapid-add.

The nav-guard's Save-all keeps passing `reveal: false`; saving drafts on the way
out must never open anything.

New e2e pins both destinations and, on both viewports, that the pathname never
changes and `?new=1` is never set. Two traps worth recording: the created card
renders off the SYNCHRONOUS local-index upsert, so it is not a sync point for
the navigation that follows — the first draft of both tests passed against the
reverted fix because the URL assertions raced an unresolved `goto`. Desktop now
waits on `?item=`; mobile can't poll an absence, so it proves the negative
positively by re-opening the lane composer (only possible if the board is still
mounted, and awaiting it gives a would-be navigation time to land). Verified by
mutating the fix back out: both fail for the right reasons, pass on the fix.

Gates: npm run check 0 errors, npm run test 490 passed, new spec 2/2, pane e2e
64/65 (the one failure is the pre-existing BUG-2334 SSE-toast flake, confirmed
by screenshot and passing in isolation). Codex review CLEAN (CONVE-735).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-26 19:04:50 -04:00
xarmian 74813fcc72 revert(web): restore the pre-TASK-2328 item action bar, then make it fit (PLAN-2326 overturned) (#1035)
* Revert "feat(web): dissolve the item action bar into a new .tab-strip wrapper (TASK-2328) (#1033)"

This reverts commit 10a5ae2271.

* fix(web): action bar holds one row and compresses to the container width

The band was `flex-wrap: wrap` with a hard `min-width: 70px` per button, so
five controls (star + quick actions + children + backlinks + overflow) needed
~340px and wrapped to a second row in any pane narrower than that.

Replace the hard floor with `flex: 0 1 70px` scoped to `.meta-actions`: the
70px basis reproduces the old width when there is room, so nothing moves on a
wide container, and `min-width: auto` bounds the shrink at each button's own
label rather than clipping it. The graph drawer's `.action-btn`s keep the plain
floor — their labels are wider than 70px.

`.menu-anchor` and `.quick-actions-menu` become flex so the ⋯ /  triggers they
wrap participate in the compression instead of sitting at block min-content.

Below a 340px band a container query reclaims 4px of inline padding per side,
which covers the 312px pane minimum (the draggable floor) with the full control
set — measured 0 overflow there, and 0 with a 3-digit child count. Deliberately
not `overflow-x: auto`: an invisible scrollport is what made controls silently
unreachable in TASK-2328.

Also narrows the button `transition: all 0.1s` to the three hover properties.
Now that width is container-derived, `all` animated padding during a pane drag.

Measured in Chromium at 264-912px band widths: no wrap and no clipped label at
any width; anchored ⋯ menu still escapes the new container (panel renders 162px
below the band); mobile BottomSheet still resolves against the viewport
(390x844) rather than the container, for both the ⋯ and  menus.

Gates: svelte-check 0 errors, 490 vitest, 39 e2e across the five specs that
drive these controls.

* fix(web): one width and one height for every action-bar control

The  quick-actions trigger belongs to QuickActionsMenu and never carried
`.action-btn`, so it rendered 41x22 beside its neighbours' 70x26 — a different
width AND height, which is what read as awkward. Give its wrapper the same
70px basis, let the trigger fill it, and set the band's box metrics in one
place instead of two.

The ⋯ overflow trigger is the deliberate exception and now sizes to its glyph
(38px). That needs `min-width: auto` as well as the flex change: `.action-btn`'s
base `min-width: 70px` reaches it as a grandchild, so the direct-child override
missed it and a 70px floor held it wide regardless of flex-basis.

Pin `line-height: 1.35` so glyph metrics stop leaking into the height — "⋯" and
"☆" resolved 1px apart, which `align-items: center` then showed as a misaligned
row — and take block padding to `--space-2` for the requested ~30% more height:
26.1px -> 34.1px (+30.7%).

Measured at 216-864px band widths: one height (34.1px) everywhere, no wrap and
no clipped label at any width, ⋯ exempt at 38px. Uniform width holds wherever
the row has slack; below ~382px the controls necessarily diverge as each
compresses toward its own label, and a label wider than 70px (a 3-digit child
count) still grows past the basis rather than truncating.

Gates: svelte-check 0 errors, 490 vitest, 39 e2e.

* fix(web): harden the  wrapper selector + pin the sheet-containment invariant

Codex review findings on 92f8a6e2 / 5bd799aa.

P2 (real): `.meta-actions :global(.quick-actions-menu)` was (0,2,0), exactly
tying QuickActionsMenu's own scoped `.quick-actions-menu.svelte-<hash>`
`display: inline-block`. Cross-file stylesheet order was the only thing making
`display: flex` win, so a chunking change could silently restore inline-block:
the wrapper would keep the 70px basis while the  inside snapped back to
intrinsic width, undoing the uniform width and shrinking the touch target. The
`div` qualifier takes it to (0,2,1) and wins outright.

P1 (refuted, then pinned): Codex read the Containment spec to mean
`container-type: inline-size` establishes a fixed-position containing block, so
the mobile BottomSheet — a non-portaled `position: fixed` descendant of the band
— would collapse into a ~342x34 strip. Measured in Chromium it does not: the
overlay is confirmed a DOM descendant of `.meta-actions[container-type:
inline-size]` and still resolves to the full 390x844 viewport, for both the ⋯
and  menus.

Since that rests on engine behaviour rather than a guarantee, add e2e coverage
instead of just asserting it. The new spec checks the premise (band really is a
query container, and much smaller than the viewport) before the invariant, and
fails loudly rather than vacuously if BottomSheet ever starts portaling.
Mutation-tested: adding `contain: layout` to the band collapses the overlay to
the band's width and the test fails with "overlay spans the viewport width"
(expected 412, received 364) — which also demonstrates `contain: layout` and
`container-type: inline-size` are NOT equivalent here.

The same spec pins the uniform width/height and the no-wrap, no-clip invariants
on desktop. jsdom computes no layout, so none of this is unit-testable.

* docs(web): correct the containment claim; cover both menus in the sheet test

Codex nit, and it changes the mechanism rather than just the wording.
`container-type: inline-size` applies STYLE and INLINE-SIZE containment, not
layout containment (css-conditional-5 §container-type). Layout containment is
what establishes a fixed-position containing block, so the mobile sheet is safe
BY SPEC, not by engine luck — my comment and the spec header both repeated
PLAN-2326 DR-3's claim that `inline-size` implies `contain: layout style
inline-size`, which is wrong, and wrong in the direction that makes an unsafe
change look safe. Codex reached its P1 from the same bad premise.

Reframed accordingly: the standing hazard is not a future engine, it's someone
adding `contain: layout` (or a transform/filter) to this band later. Both
comments now say that explicitly.

The sheet test also only drove the ⋯ menu while the commit message claimed both.
It now loops over ⋯ and  — separate wrappers with separate styling, so one
does not establish the other — and throws rather than skipping if the  trigger
is missing on an owner-viewed item.

* fix(web): put the action-bar control height back to 26.1px

The ~30% taller controls (34.1px, --space-2 block padding) were rejected on
review — desktop first, then mobile too. Back to --space-1 and the band's
original 26.1px on every surface, so no per-breakpoint split is needed.

The uniform sizing from 5bd799aa stays: all four controls are one height rather
than the 26/22/25 they were before, and the  trigger still matches its
neighbours instead of sitting 4px short.
2026-07-26 10:09:10 -04:00
xarmian 8bc3c5f4c9 fix(e2e): graph tests open the drawer via the pane ⋯ overflow (missed in #1029 — only capstone/host were re-run locally) (#1030) 2026-07-24 21:06:13 -04:00
xarmian 26c3f02136 feat(web): pane action bar consolidates into the ⋯ overflow (TASK-2294 PR B) (#1029)
PLAN-2290 Phase 4, PR B. The pane's action bar becomes the mock's trio —
star, quick actions, ⋯ — with the count-carrying jump badges (🌳 done/total,
📎 N) retained as tab shortcuts:

- Dependency graph / Move to collection… / Share… / Delete… move into a
  pane ⋯ Menu (primitive; BottomSheet on mobile; Move is a drill-down view
  inside the same panel, LaneActionsMenu precedent — replaces the old
  standalone move dropdown/sheet + showMoveMenu state).
- The redundant Timeline text button is removed (the Activity tab IS the
  timeline entry point).
- The Delete… row opens the existing inline confirm strip in the bar;
  handleMove/reset paths repointed to the new menu state.
- Capstone e2e updated: pre-peek opens the ⋯ and asserts the rows; while
  peeking asserts the trigger stays enabled (the BUG-2263 liveness
  guarantee) instead of opening — opening would activate the side.

Gates: svelte-check 0 errors, 488 unit tests, capstone+host e2e 16/16,
⋯ menu runtime-verified (screenshot).
2026-07-24 20:37:33 -04:00
xarmian d04b714ccb feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294) (#1027)
* feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294)

PLAN-2290 Phase 4, PR A. The mock's tabbed pane, built on the hard rule:
panels are CSS-hidden (.tab-hidden, display:none), NEVER {#if}-unmounted —
the collab editor, ChildItems/ItemTimeline SSE subscriptions, and
BacklinksPanel's count callback all carry mount side effects that must
survive tab switches.

- ItemDetail: pane-tabs tablist after the action bar; Details wraps Code
  Context + .item-body (fields+editor, layout-{layout} preserved);
  Relationships wraps relationships/add/children/backlinks (inside the
  existing {#key itemSlug} block); ONE ItemTimeline instance serves both
  Activity and Versions via the new visibleKinds render-filter. Tabs reset
  to Details on item switch (guarded plain-let effect, no read-write loop).
  Jump buttons switch-tab-then-scroll. Print shows all panels, no tab bar.
  Tab clicks stay interactive while peeking and activate the side per the
  focus-follows-editing model (deliberately NOT an exempt surface).
- ItemTimeline: visibleKinds?: ('comment'|'activity'|'version')[] —
  filter-only over the one merged feed (no refetch on switch); composer
  renders only when comments are visible.
- E2E: five specs updated — tab-click preludes where interactions target
  tabbed sections; four frozen-master tests reworked to assert per-tab
  visuals BEFORE the peek and DOM-based freeze proxies during it (the
  per-surface freeze audit lives in masterFreeze/mutationGate unit suites).

Gates: svelte-check 0 errors, 488 unit tests, the five affected e2e specs
27/27 locally; runtime-verified collab badge synced across a full tab
round-trip, version filter (4 real cards), composer placement, editor DOM
alive throughout.

* fix(web): pane-tabs review fixes — title-Enter surfaces Details before editor focus; ARIA ids/roving-tabindex/arrow nav; block-drag hover integration restored via re-peek

Codex findings on #1027: (1) Enter-after-title-edit now sets
activeTab='details' + tick before focusing the editor (was focusing a
display:none node from other tabs); (2) tablist gains arrow-key roving
focus, per-instance aria-controls/id pairing ($props.id() — two ItemDetail
instances mount on the full-page host), tabindex discipline; (3) host
test 3 regains the end-to-end hover assertion: re-surface master Details
(activates), re-peek via the pane, hover the frozen editor, assert the
handle stays display:none — the reactive-editable choke verified in
integration again, not just by contenteditable proxy.

* fix(web): pane tabs use automatic activation on arrow nav (Codex — roving tabindex must follow focus; activation is free on display-toggled panels)
2026-07-24 19:30:00 -04:00
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 222c596a96 test(e2e): add BLOG-2289 v0.11 pane screenshot capture block (#1016)
Reusable blog-screenshot capture for the Pad v0.11 detail-pane post
(pad-web/static/blog/pad-v0-11-item-pane/01-item-pane.png). Follows the
existing BLOG-1007 / BLOG-1704 pattern; gated on PAD_BLOG_SCREENSHOTS=1
so it never runs in normal CI. Opens the docked pane via ?item=<ref> on
a seeded, content-bearing task, and logs the browser session first so the
pane's collab editor hydrates (WS auth is cookie-based) instead of
capturing a loading skeleton.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-23 16:38:55 -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 06d7e429e5 fix(web): keep the inline "New quick action" form open on click (BUG-2281) (#1005)
Clicking the QuickActionsMenu footer "+ New quick action" flipped
showCreateForm=true, unmounting the footer's {:else} branch (the very
button clicked). Svelte 5 flushSyncs after a delegated event handler, so
by the time that click bubbled on to the <svelte:window> click-outside
handler the button was detached — target.closest('.quick-actions-menu')
returned null, and handleWindowClick treated it as an outside click and
closed the whole menu, wiping the create form the instant it opened. The
create-form Cancel button had the same detach-then-close quirk (closed
the menu instead of returning to the action list).

handleTriggerClick already guards this with e.stopPropagation();
handleOpenCreateForm and the Cancel handler did not. Add the same guard
to both. Adds a Playwright regression test (mutation-tested: fails on the
pre-fix code, passes after) — the inline form is exercised in a real
Chromium event pipeline for the first time (jsdom doesn't reproduce the
mid-bubble detach, and the capstone spec only asserted the button was
visible, never clicked it).

Also documents BUG-2280 in ItemDetail.svelte: the QuickActionsMenu
oncollectionupdated callback's `{@const keyedSlug = itemSlug}` fence was a
Svelte-5 no-op, but the callback is already switch-safe by two independent
layers (the child-side collection-id guard reads the LIVE parent
collection and drops a cross-collection callback; loadData's identity
clause forces the correct collection regardless). Replaces the dead no-op
fence with a comment explaining why it's safe and warning against
re-adding a no-op snapshot fence (the literal BUG-2129 trap). No
behavior change in ItemDetail.

BUG-2281: real, fixed. BUG-2280: investigated, not a live bug (wontfix).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 16:50:23 -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 9d18f12893 fix(collab): flush live editor into items.content before version restore (BUG-2271)
Before a version restore, the initiating client flushes its live collab editor markdown into items.content (the collab server is a dumb relay and can't render the Y.Doc), so the server-side 'Restored from…' undo-point captures in-flight edits instead of losing them when the restore prunes the op-log. Best-effort: a genuinely-failed pre-restore flush warns the user (non-silent) and the restore still proceeds. Narrow reconnect/cursor-0 window documented as an accepted residual.

Confirming Codex (high effort): 2 rounds — silent-flush-fail + spurious-warning + E2E false-pass all closed; deterministic request-ordering E2E green. Web CI red only on the pre-existing npm advisory (BUG-2278).

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 17:27:02 -04:00
xarmian 1f12b26f33 fix(web): item field edits use fields_patch + optimistic concurrency (BUG-2273) (#991)
Adopt the IDEA-1480 / MCP v0.14 item-level merge + optimistic-concurrency contract in the web editor's per-field save. `updateField` now sends a single-key `fields_patch` + `expected_updated_at` instead of a full `fields` blob, with a bounded refetch-and-retry on 409 `update_conflict`. Fixes concurrent-field-edit clobber and the schema-migration-race value restore. Includes the BUG-2129 E2E test update to the new wire shape.

Confirming Codex pass: CLEAN. E2E green on rerun. (Web/Go CI red only on pre-existing dependency advisories tracked in BUG-2278.)

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 16:08:55 -04:00
xarmian 40f88052cd fix(collab): version restore via prune+reseed (BUG-2264) (#990)
Version restore didn't reconcile the live Y.Doc: peers kept editing a
Y.Doc built on pre-restore ops, and their next collab-snapshot flush
clobbered the restored items.content. Reworked restore to prune+reseed —
the restored content becomes canonical and every peer converges on it
(unflushed edits are discarded, which is exactly restore semantics),
replacing the earlier applier/epoch/watermark routing.

handleRestoreItemVersion drives RoomManager.ForceRefreshRoom under the
per-item lock. Hardened across Codex xhigh review rounds:

- Atomicity: pre-prune MAX(op-log), the items.content write, the
  "Restored from…" version, the op-log wipe, AND both durable restore
  boundaries all run in ONE store transaction. A failed commit rolls back
  all of it — no divergent state, no fail-open boundary.
- Unambiguous commit signal: UpdateItem reads the updated row WITHIN the
  tx (getItemTx) before commit, so a read failure can't make a committed
  update look failed and the returned seq is this restore's.
- Restore freeze: conns are paused via a dedicated rc.frozen flag (NOT
  canWrite) so the auth-revalidation loop can't thaw the freeze mid-restore
  or promote a viewer; pickApplier + the applier-ack handler reject frozen
  conns so a concurrent external PATCH can't falsely succeed.
- Stale-flush boundary: pre-prune MAX+1 fences in-flight snapshot cursors
  under the same item lock.
- force_refresh fan-out deadlock: per-conn timer-close so a wedged
  writeLoop can't hang the fan-out + item lock.
- Stale-SEED clobber: the client announces the item.seq it seeded from
  (?content_seq=) on every (re)connect; Join force_refreshes any seed that
  predates the last restore.

Residual #1 (restart-durability) CLOSED durably, for BOTH stale vectors —
the in-memory fences didn't survive a restart, so a surviving cursor-0
pre-restore browser tab wasn't fenced on reconnect. Two nullable per-item
columns (migration 075 SQLite / pg 053), both stamped in the restore's own
tx (atomic with the content write + op-log prune):
  * items.last_restore_seq — the content generation. Join's stale-seed
    fence reads it (via store.ItemLastRestoreSeq) when the in-memory
    fast-path misses (after a restart); if that read errors, Join fails
    CLOSED via a RETRYABLE plain close (not a force_refresh, which would
    discard the Y.Doc and spin an unbounded refresh loop) so the client
    reconnects with backoff, Y.Doc intact.
  * items.restore_boundary_op_id — the op-log-id boundary. The
    collab-snapshot flush gate reads it (via store.ItemRestoreBoundaryOpID)
    when the in-memory RestoreBoundary misses (after a restart), failing
    closed (409) on a read error, so a surviving tab's stale HTTP flush is
    fenced too.
No SCHEMA_VERSION bump — durable columns are not a Y.Doc node-spec change.

Deferred to BUG-2276: (a) a Postgres commit whose ack is lost is treated
as rolled-back (needs commit-outcome reconciliation; SQLite unaffected);
(b) a restore rollback racing an in-flight external-applier ack can drop
the ack and retry/fall back (needs the applier flow serialised under
itemLock at a 30s-stall cost).

NOTE(BUG-2270): ForceVersion can mint same-second version rows; the
item_versions ordering tie-breaker is tracked separately.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 15:21:57 -04:00
xarmian d14bceb3e2 fix(web): render the Rich/Markdown mode toggle on the peeking side too (BUG-2263 follow-up) (#988)
The invisible-freeze work (PR #987) left ONE surface still gated on
`!peeking`: the editor's Rich⇄Markdown mode toggle. It was hidden on the
passive preview because it's a provider-LIFECYCLE control — switching to
Markdown nulls collabKey and DESTROYS the retained collab provider, which
retain-alive (D2) forbids WHILE peeking.

But under focus-follows-editing a click on the toggle fires the host's
pointerdown-capture activator FIRST, flipping activePane to that side
(peeking=false) before the click's onclick runs. So by the time the flip
executes, the side is already ACTIVE and tearing down its own provider is
normal active-side behavior — the "teardown while peeking" the gate feared
can't happen via a click. The onclick's existing `if (peeking) return`
guards (plus the `|| peeking` mid-flush rechecks) remain as the backstop
for a re-peek DURING the async flush (e.g. the user clicks the other side
mid-flip).

So drop the `{#if !peeking}` render gate — the toggle now renders on both
sides like every other invisible-freeze surface. Verified in the browser:
opening the pane shows the toggle on the peeking preview; clicking the
peeking master's "Markdown" button activates it and flips to raw mode in
ONE gesture, ProseMirror unmounts cleanly, exactly one typeable editor
throughout, zero console errors.

Tests: FreezeProbe renders mode-toggle unconditionally; masterFreeze
asserts it present while peeking; new host e2e opens the pane, confirms the
toggle on the peeking side, and asserts the one-gesture flip (a successful
flip proves activation preceded the guarded onclick).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-20 20:06:13 -04:00
xarmian 29e49e4c63 fix(web): make the master/pane freeze invisible to the user (BUG-2263) (#987)
On the full-page item host, opening a detail pane froze the non-active
side by DEGRADING its DOM — fields became plaintext, buttons vanished,
the title turned read-only. Under the focus-follows-editing model
(PLAN-2179) the freeze is transient and one-click-reversible, so that
degradation was pure user-visible friction: you'd click a plaintext
field, the click would flip activePane, the field would re-render into a
live control, and you'd have to click again.

The freeze exists ONLY to keep exactly one TYPEABLE collab content editor
(single-owner of the editorStore/activeItem/tab-title singletons). It is
NOT a data-collision barrier: master and pane are always DIFFERENT items,
whose collab state is fully itemID-keyed / instance-local, and most REST
surfaces (fields, title, assign/role, tags, move, delete, share,
relationships, children, comments, reactions, archived restore, star) are
single-item, server-gated, side-independent writes.

So drop the `!peeking` term from those REST surfaces — gate them on
`canEdit` alone (their pre-freeze contract) — and keep `peeking` ONLY on
the content editor and its chrome (rich + raw editors, bubble/link
popover, provider-lifecycle mode toggle + retry). The content editor is
already invisible: the host's pointerdown-capture flips activePane before
the click's caret placement (TASK-2180 no-remount reactive editable), so
one gesture activates the side and lands the edit. Now the whole side is:
click anywhere -> edit it, no visible mode.

Two surfaces are NOT side-independent and stay confined to the active side
(the two documented exceptions, found by Codex review):
 - Version restore REST-writes this item's `items.content` directly, which
   collides with the retained Y.Doc on a peeking side. Kept frozen via a
   new ItemTimeline `restoreFrozen={peeking}` prop; comments/reactions
   (separate REST entities) stay live.
 - The quick-actions "Manage/New" controls rewrite the whole collection
   `settings` from a per-item snapshot (last-write-wins across two items in
   one collection), so they gate on `isOwner && !peeking` and recheck
   canEdit at dispatch; the read-only prompt-copy actions stay visible on
   both sides.

Scope: full-page host only. The collection route never passes peeking, so
`mutationsEnabled === canEdit`, `frozen={peeking}` is inert, and
`restoreFrozen` defaults false there — every change is byte-identical on
that route. `mutationsEnabled` survives but now scopes to content-editor
chrome only.

Tests: rewrote the masterFreeze unit probe + both full-page e2e specs
(host + capstone) to assert the new contract — the frozen side keeps its
editable title/fields/buttons; only its content editor flips
contenteditable=false. The freeze signal moved from `h1.title-readonly`
to the ProseMirror `contenteditable` attribute. Added a runtime-mutation
e2e assertion (a field edit on the frozen master PATCHes the correct item),
a real-QuickActionsMenu integration test, and unit coverage for the two
exceptions.

Two PRE-EXISTING concurrency issues were surfaced by the review (version-
restore gating + collection-settings write-exposure are byte-identical to
main, so this PR neither introduces nor worsens them); filed as BUG-2264
(restore <-> Y.Doc reconciliation) and BUG-2265 (collection-settings
optimistic concurrency).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-20 18:55:25 -04:00
xarmian b447ce9938 feat(web): focus-follows-editing (activePane) on the full-page pane host (TASK-2181) (#986)
* feat(web): focus-follows-editing (activePane) on the full-page pane host (TASK-2181)

On the full-page item host, editing now FOLLOWS FOCUS: opening a pane keeps the
master editable and shows the pane as a read-only preview (DR-2); clicking a side
makes it the editable one and freezes the other. Exactly one side is editable at
any moment — the two-editor collision the freeze prevents still holds, now
dynamically. Builds on the TASK-2180 reactive freeze, so flipping `peeking` is a
cheap toggle (no editor remount).

- Host route ([collection]/[slug]/+page.svelte): adds `activePane: 'master'|'pane'`
  ($state, seeded viewport.isMobile ? 'pane' : 'master' for cold-load; forced to
  'pane' on the mobile-breakpoint transition). Master `peeking={!!openItemRef &&
  activePane==='pane'}`. First-open re-seeds the active side; drill / in-pane Back /
  ESC-pop set 'pane' via the controller's focusPaneRegion dep (single wire point).
  A document focusin classifier + capture-phase pointerdown activator flip
  activePane only on a CHANGED region, classifying against BOUND elements
  (itemPageEl / PaneHost.getPaneRegion()), exempting portalled surfaces (shared
  inExemptSurface) and ignoring bare-<body> drops.
- PaneHost.svelte: optional `activePane` prop (unset on the collection route →
  byte-identical). Forwards `peeking={activePane==='master'}` to its inner
  ItemDetail; exposes getPaneRegion(); the desktop focusin backstop only pulls
  focus back while activePane !== 'master' (so it no longer fights master
  activation; collection-route behavior preserved when the prop is unset).
- paneFocus.ts: extracts the shared inExemptSurface() set (reused by the mobile
  trap and the host classifier).
- ItemDetail.svelte: a FROZEN (peeking) instance never claims the singleton
  collectionStore.activeItem / editorStore — loadData gates setActiveItem +
  resetForDoc + setLastSaveTime on !peeking; onDestroy gates resetForDoc on
  !wasPeeking; the freeze-END reclaim is gated on itemMatchesRef and restores
  editorStore dirty/lastSaveTime from the instance's local shadows, so the
  singletons always follow the active side.
- +layout.svelte: fences the self-save-suppression's late setActiveItem
  continuation on the current activeItem (the ping-pong can switch sides mid-await).

Scope: full-page-host only; the collection route is untouched (pane stays always
editable there). No {#key} added (freeze is reactive). Known deferred R9
singleton-editorStore coupling for in-flight saves tracked in BUG-2184.

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

* fix(web): frozen side never writes singleton editorStore on remote collab; first-click drill from a frozen preview (TASK-2181)

Two in-scope fixes from an independent review pass:

1. A FROZEN (peeking) ItemDetail must not clobber the singleton editorStore that
   the ACTIVE side owns when REMOTE collab traffic syncs into its still-live Y.Doc.
   handleContentUpdate's collab path now gates editorStore.setDirty(true) on
   !peeking, and the collabFlusher save callback gates the singleton
   setLastSaveTime/setDirty(false) on !peeking. The per-instance shadows
   (localDirty/localLastSaveTime), the retain-alive snapshot persistence
   (collabFlusher.schedule), and the per-instance saveStatus/showSaved all stay
   unconditional — so the un-freeze END-reclaim still restores correct values and
   +layout's self-save suppression for the active item is no longer corrupted by a
   preview's background sync. (Distinct from BUG-2184's deferred pre-freeze
   continuation.)

2. A content-link / child-row drill from a FROZEN preview now works on the FIRST
   click. The focusin + capture-phase pointerdown detectors exclude navigable drill
   targets (isNavigableDrillTarget = closest('a[href]')), so they no longer flip
   activePane mid-gesture — which re-inited ChildItems' live dndzone (dragDisabled
   tracks the freeze) and swallowed the click. The click's own drill
   (navigatePaneTo → focusPaneRegion) sets activePane='pane', so the link drills AND
   activates the pane in one click. Exclusion covers BOTH detectors because
   Chromium/Firefox focus an <a> on mouse-click. Removed the capstone's
   pane-activation workaround and assert the first-click drill instead.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-20 13:37:42 -04:00
xarmian 3525171886 refactor(web): reactive master/pane freeze — drop the peeking-driven editor remount (TASK-2180) (#985)
Make the `peeking` master-freeze work WITHOUT remounting the Tiptap editor, so
a future focus-follows model can flip it on every focus switch smoothly
(PLAN-2179 DR-1). Behavior stays byte-identical for `editable=true` callers —
this only ADDS runtime `editable` gates and removes a remount.

BlockDragHandle was the SOLE construction-gated freeze surface (registered via
`editable ? [...] : []` in Editor.svelte). Make it reactive-editable-aware
instead:
- Register BlockDragHandle unconditionally.
- Choke handle visibility on `editorView.editable` in onMouseMove + the plugin
  update() (ProseMirror recomputes view.editable before plugin views update, so
  a freeze hides the handle synchronously — no remount).
- Bail every mutation dispatch site on `!editorView.editable`: startDrag,
  executeMove, the endDrag move dispatch, showMenu, and the four menu-action
  listeners (turn-into, duplicate, delete, attach) plus the deferred
  attach-picker change handler.
- Drop `peeking` from ItemDetail's editor `{#key}` (keep item.id +
  forceRefreshNonce). The freeze now works via `editable={!peeking}` reactively.

Dropping the remount reopened the "late async continuation / lingering UI"
holes the remount used to close by destroying the editor. Gate each reactively
(no remount reintroduced) so a frozen master still can't be mutated:
- EditorBubbleMenu create: drop the wiki-link insert when `!originEditor.isEditable`.
- htmlBlock commit(): bail on `!editor.isEditable` (a block already in source
  mode keeps its native textarea editable across the freeze).
- Editor slash / `[[` pickers: bail execSlash/execLink on `!editor.isEditable`,
  render-gate the menus with `&& editable`, and dismiss them on the freeze.
- ItemDetail source-refresh: add `!targetEditor.isEditable` to the post-await
  guard so a pre-pane-confirmed content REPLACE drops on a frozen doc.

Everything else the old remount cited was already reactive-gated (attachment
paste/drop, clipboard, image rotate/crop via `!view.editable`; mobile/table
toolbars via `{#if editable}`) — verified. Freshened the now-stale `{#key
peeking}` comments in attachment-upload.ts / attachment-image.ts. Reactive
freeze strictly improves BUG-2177 (no freeze-driven remount to orphan in-flight
editor actions).

Tests:
- blockDragHandleFreeze.svelte.test.ts — real Tiptap editor asserts the handle
  hides on freeze, no view/DOM remount across an editable flip, every mutation
  path bails while frozen, and the delete path still fires when editable (pure
  superset).
- pane-full-page-host.spec.ts — real-browser guards: opening/closing the pane
  freezes/thaws the master editor WITHOUT remounting its DOM node
  (contenteditable flips in place); the master block drag handle appears on
  hover when editable, never while peeking, and returns on close.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-20 11:26:40 -04:00
xarmian 3725d968e9 test(web): Phase-2 CAPSTONE e2e for the full-page pane host (TASK-2175) (#981)
Capstone runtime verification for PLAN-2154 Phase 2 (the full-page pane
host, TASK-2170–2174), mirroring the Phase-1 R14 async-race capstone
(TASK-2167) but exercising the properties the full-page host alone
introduces:

- Option-A mutation-SILENCE (the D2/HT-2176 freeze acceptance): while a
  pane peeks beside the retain-alive master, the key NEW-edit-INITIATION
  surfaces are disabled/absent (title click-to-edit, field inputs, comment
  composer, contenteditable editor, star/Share/Quick-actions/Move/Delete/
  relationship add+remove); un-peeking restores them. Asserts initiation
  surfaces, NOT zero writes (a pre-pane pending save + remote collab sync
  are expected under Option A). Exhaustive per-path freeze stays unit-tested.
- Bounded two-WS cost while peeking: opening the pane yields master +
  pane = 2 collab WS to distinct rooms (total-live ceiling asserted), the
  master's own room never gets a second provider, a drill re-targets the
  one pane provider (not N). Plus a WS-instrumented self-collision test:
  a cold-loaded ?item=<master> mints NO second provider on the master room.
- Host-side R14 late-async continuations: drill-right-after-Back (stale
  back-settle can't revert), close-then-reopen (stale loadData can't
  clobber — sub-resource GET proof by loaded slug), a held Back burst
  (coalesces to exactly one mint), and a rapid double-close (one
  history.go, never overshoots the master route).

Adds a localStorage-gated `__padPaneController` test hook to the host
route (mirroring the collection page's), so the R14 drill/close
continuations are synchronously drivable under adversarial timing. Zero
production surface.

Gates: svelte-check 0 errors; vitest 444 green; full pane e2e green at
--workers=1 (existing 54 + 7 new).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-19 22:30:22 -04:00
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
xarmian 2aff33d99c test(web): R14 async-race capstone e2e suite for the pane mini-browser (TASK-2167) (#975)
The R14 fence-on-continuation sweep of every pane-controller async
continuation site (schedulePaneFollow debounce, paneHistoryGo + afterNavigate
latches, the popstate mint-settle, and ItemDetail's loadGeneration-gated
loadData/SSE/save continuations) confirmed all sites are already fenced by
TASK-2155/2156/2157/2161/2162/2166 — this adds the capstone suite that
exercises those fences end-to-end under adversarial timing so a regression at
any one fails loudly.

Eight tests, each asserting the R14 invariant (no late continuation writes
stale state over a newer item — no ?item= clobber, no depth desync, no crossed
content, no stale field write-back), made deterministic and non-vacuous
(route-gated stale requests proven to enter the gate and drain; fenced-out
actions proven to issue no fetch; crossed-Y.Doc caught at the editor-body
level). Each timing test runs in its OWN fresh collection so its list is
deterministic and it doesn't bloat the shared docs list:

1. a late j/k follow surviving a popstate-forward drill — isolates the
   continuation-time depth RE-CHECK from the cancellation belt (the follow
   target is held distinct from the forward target so the same-item guard
   cannot be what saves it);
2. a drill fired right after a browser Back, mid mint-settle;
3. a close + drill fired during an in-flight cold-base close traversal + latch
   (proves the fenced-out drill target is never fetched);
4. a rapid ?item= re-target while a route-gated loadData GET is outstanding
   (title + field + editor body all stay on the newer item);
5. a deep browser Back/Forward traversal through a drilled stack (editor-body
   locked per hop);
5b. a genuine multi-popstate Back BURST (each back chained off the preceding
    popstate; 3 events asserted) coalescing via the mint-settle;
6. a cross-collection Back onto an open pane — paneMintForRoute route-reuse
   clamp, proven by the fetch sequence (destination item fetched, stale source
   never re-fetched);
7. a loadData GET resolving after the pane closed and a new item opened writes
   nothing over the new item.

No production code changes: the audit found no unfenced continuation to close.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-19 03:46:30 -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