mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
7e20e297542705acdeebf3df85effaae80fe211d
67 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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)
|
||
|
|
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) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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.
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
fbfbfcfe34 | test(web): e2e coverage for the copy/move dialog (TASK-2355) | ||
|
|
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
|
||
|
|
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 |
||
|
|
8bc3c5f4c9 | fix(e2e): graph tests open the drawer via the pane ⋯ overflow (missed in #1029 — only capstone/host were re-run locally) (#1030) | ||
|
|
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). |
||
|
|
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)
|
||
|
|
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). |
||
|
|
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 |
||
|
|
faf9b3734a |
feat(web): default new collections to Board — schema-aware (IDEA-2274, IDEA-2287) (#1015)
* feat(web): default new collections to Board view (IDEA-2274) Board becomes the baseline default view for new collections; existing collections keep their stored default_view (no migration). - Frontend fallback (settingsDefaults, collection-page defaultMode, shareView coerce, initial viewMode) -> board - Create/Edit collection modals default -> board - Backend template seeds (defaults.go, templates*.go) list -> board for ideas/plans/docs/hiring/interviewing collections (tasks was already board) - CLI `pad collection create` and MCP mapCollectionCreate defaults -> board - Curated create-modal presets with deliberate list curation (Meeting Notes, Decisions, OKRs) intentionally left as list - Pin the three list-keyboard-nav pane E2E tests to ?view=list Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): board default reaches public share page + ItemDetail fallback (Codex round 1) Codex review found the public share route (s/[token]) derives its owner default view via a separate `?? 'list'` fallback that bypassed the coerceSettings change, so settings-less/legacy collections rendered List on public share pages. Align it (and the pre-init selectedBase) to board. Also align ItemDetail's inline CollectionSettings fallback (default_view is unused there, but keep it consistent with settingsDefaults). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(collections): group Contacts board by relationship, not status (Codex round 2) Contacts has no `status` field, so defaulting it to Board grouped by the default `status` rendered every card in a single Uncategorized lane. Set BoardGroupBy=relationship so the board shows real lanes. All other board-defaulted seed collections have a status field or an explicit board_group_by (verified: Companies/Conventions/Playbooks/Docs have status). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): always serialize ?view= so a List URL survives a board default (Codex round 3) buildCollectionUrlParams treated List as the implicit URL view and omitted it. With Board now a possible collection default (IDEA-2274), a List selection on a board-default collection produced a URL that, when copied or opened without the sender's localStorage, resolved back to Board. Always serialize the view mode; add a covering unit test. Verified the pane E2E suite (URL-equality assertions) stays green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
8c710e1db9 |
fix(web): stop paneOverlay ref-count effect self-looping on mobile (BUG-2284) (#1009)
PR #1007 (TASK-2131) added a PaneHost `$effect` that calls `paneOverlay.enter()`/`leave()` to inert the app-shell chrome behind the mobile detail-pane overlay. `enter()`'s `overlayCount += 1` READS `overlayCount` inside that tracked effect scope, so the effect took a reactive dependency on the very signal it writes: enter() dirtied the effect → it re-ran → enter()d again → `effect_update_depth_exceeded`. Svelte aborts the flush, stranding the rest of the subtree's reactivity — `paneMintForRoute` stopped recomputing, so the mobile pane (and its Back chevron) rendered EMPTY. The E2E `pane-controller` mobile-overlay tests caught it; #1007's own manual check verified the ARIA attributes but not that item content still rendered. Fix: `untrack` the count read in enter()/leave() so a write from an effect never establishes a self-dependency (the write still notifies the layout reader). The ref-count mutators are written from effects by design, so the untrack belongs in the store. Also fixes the second collision from the same #1007 change: the pane is now `role="dialog"` on mobile, so pane-controller.spec.ts:771's `[role="dialog"]` + text locator matched BOTH the pane and the BottomSheet (strict-mode violation). Target the sheet by accessible name ("Quick actions") instead — the pane's is "Item detail". The effect_update_depth_exceeded runaway only manifests under the real browser scheduler (not jsdom/vitest), so the E2E overlay tests own the loop regression; the unit tests lock the ref-count semantics. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
57da084a32 |
feat(web): focus per hop for the detail-pane mini-browser (TASK-2162) (#972)
* feat(web): focus per hop for the detail-pane mini-browser (TASK-2162)
PLAN-2154 Architecture C / R1. Each in-pane hop (a content-link drill or
an in-pane Back) changes `?item=`, remounting ItemDetail's `{#key itemSlug}`
subtrees and destroying the just-activated link/row; `keepFocus` then drops
focus to `<body>`, where the next `j`/`k` runs list-nav and can laterally
re-target the drilled `?item=`, corrupting the stack.
Host now owns focus-per-hop: `focusPaneRegion` moves focus onto the STABLE
aria-labeled `<aside>` synchronously at each drill (`navigatePaneTo`) and
Back-button pop (`handlePaneBack`), before the imminent remount can drop it.
This covers the editor-link keyboard path (EditorLinkPopover hides its
popover — removing the focused anchor — synchronously) since it funnels
through the same `navigatePaneTo` chokepoint. A narrowed desktop `focusin`
backstop mirrors the mobile trap, re-pulling focus into the pane only when
it drops to `<body>` from within `.item-pane` (the list stays reachable).
The depth-aware ESC pop removes no focused control, so it deliberately does
not force focus into the pane — that would fight the depth-0 return-to-list.
Resolves the focus edge TASK-2164 deferred here: its component-local
armed-`pendingBackFocus` restore (which could mis-fire onto a pane control
on a coalesced Back-then-Forward no-op) is retired in favor of this
deterministic host-owned focus — the explicit host resolution signal
TASK-2164 round 10 identified as the real fix. `onBack` is now a pure notify.
Tests: focus-per-hop e2e (mouse + keyboard drill land focus in the pane
mid-load with the destination gated so only `focusPaneRegion` could have done
it; j stays inert; in-pane Back keeps focus in the pane). TASK-2164's
Back-chevron focus assertions reconciled to assert focus-in-pane (the actual
acceptance criterion) rather than a specific button. The depth-0 ESC test now
asserts the standard two-level return-to-list-then-close, which focus-per-hop
correctly restores (drilling no longer strands focus on `<body>`).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): run focus-per-hop on mobile + the ESC pop (Codex round 1)
Two P1s from review:
- focusPaneRegion no-op'd on mobile while pendingBackFocus (which was NOT
desktop-gated) had been removed, so a mobile Back/drill could strand focus
on <body> behind the overlay if the mobile trap's focusin missed the
removal. Focus the stable paneEl on mobile too — a synchronous belt that
supersedes what pendingBackFocus gave the mobile Back path (j/k is already
inert on the overlay, so this is purely the a11y "don't lose focus" belt).
- The depth-aware ESC pop skipped focusPaneRegion; if the user had Tabbed to
a control the pop then removes (e.g. the Back chevron via the header swap),
focus could strand on <body>. Land focus on the stable paneEl at the pop.
It targets paneEl (never a removed control) so it needs no focusin, and it
doesn't fight the depth-0 two-level ESC — that's a separate later press that
correctly returns focus to the list from within the pane.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
a0299321ca |
feat(web): in-pane Back chevron for the pane chrome (TASK-2164) (#970)
* feat(web): in-pane Back chevron for the pane chrome (TASK-2164) Render a Back chevron in the pane header (ItemDetail's embedded-chrome branch) whenever `page.state.paneDepth > 0` (PLAN-2154 Architecture C). Wired to the collection host's existing fenced `paneHistoryGo(-1)` / `paneNavInFlight()` traversal — the same mechanism the depth-aware ESC handler (TASK-2163) uses — rather than a bare `history.back()`, so a rapid double-click can't stack a second traversal (R14). Depth is read reactively via SvelteKit's `page.state` accessor inside ItemDetail itself, so a cold-loaded shared `?item=` correctly starts at depth 0 with the chevron hidden. Shared markup covers desktop and the mobile full-screen overlay alike. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): keep the Back chevron reachable in the pane's minimal header Every drill sets loading=true, which switches ItemDetail to the minimal embedded header (previously Close-only) — so a slow or failed drilled item stranded the user with no way back, worst on mobile where ESC isn't reachable. Mirror the same depth-gated chevron there (Codex review round 1). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): restore focus to the Back chevron after each pop (Codex round 2) Clicking Back triggers a reload of the drilled-to item, which briefly sets loading=true and swaps the loaded pane header for the minimal one — unmounting the just-clicked, focused Back button and dropping focus to <body>. Without this, a keyboard user popping a multi-level drill stack loses their place after every press. Track the intent with two flags (not one): `paneDepth` updates synchronously with the popstate `history.go(-1)` fires, landing before `itemSlug` changes and `loadData()` actually sets `loading = true` — a single-flag effect can observe a stale `loading === false` on that intermediate run and consume the flag before the real reload cycle even starts. `backFocusSeenLoading` requires observing `loading` go true first, so the restore only fires on the matching false after it. Also fixes a race in the new multi-hop e2e test: `drillTo()` only awaits `navigatePaneTo`'s synchronous portion (the `goto()` it fires is fire-and-forget), so firing three drills back-to-back without polling between them could read stale depth. Poll after each hop, and assert the Back button keeps focus across presses. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): key Back-chevron focus restore off itemSlug, not a loading transition (Codex round 3) Codex round 3 found a real race in the loading-transition-based focus restore: pressing Back again from the minimal header — while a PRIOR item is still loading (paneNavInFlight() only fences the history.go traversal, not the data fetch) — could let the effect latch onto that stale, already-in-flight cycle instead of its own. Investigating with instrumentation showed the generation-fenced version of the fix was actually correct in isolation, but the added console logging was itself perturbing timing enough to mask a genuinely flaky window under heavier parallel load. Replaced the two-flag, generation-counting design with a much simpler one: key off itemSlug (purely URL-derived, updates synchronously with the same popstate that stamps paneDepth, independent of fetch status) actually changing away from the ref captured at click time, then wait for loading to clear. No transition-observation bookkeeping needed. Verified with 25+ consecutive passes of the new race-condition e2e test across workers=1/2/4, including alongside the full pane suite under system load from concurrent sibling agents. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): fall back to the Close button after the terminal Back pop (Codex round 4) The last Back press (landing at depth 0) has no Back button left to restore focus to, so it was stranding focus on <body> with the pane still open. Fall back to the always-present Close button — a stable, keyboard-reachable control — rather than leaving it unhandled. Full "focus per hop" for arbitrary content-link drills stays TASK-2162's scope; this only closes the gap the Back button's own terminal press opened. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): bind the minimal header's Close button too (Codex round 5) closeBtnEl (the terminal-Back-pop focus fallback added in the previous commit) was only bound on the loaded header. If the pop's destination is slow or fails to load, the minimal header can still be mounted when the restore effect fires, leaving closeBtnEl undefined and the restore a silent no-op. Bind it on both headers, mirroring backBtnEl's existing pattern. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): fence Back-chevron focus restore against a superseding navigation (Codex round 6) Comparing only itemSlug treated ANY later navigation as "the Back pop landed" — so clicking a different row (or hitting Forward) while the Back destination is still loading would let that unrelated navigation's itemSlug change satisfy the check, stealing focus into a pane the user never asked to restore-focus on. Also left the pending flag armed indefinitely across a quick Back->Forward that never matched. Fence with loadGeneration (bumped synchronously at the top of every loadData() call, regardless of trigger): capture it at click time and require the settled generation to be EXACTLY one more — the Back click's own load and nothing else raced in ahead of it. A mismatch abandons the restore instead of stealing focus, and self-resolves the "armed indefinitely" case since loadGeneration only increases. Verified the new regression test actually catches the bug: temporarily disabled the guard, confirmed the test fails (focus lands on Close), then restored it and confirmed it passes. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): bound the Back-chevron focus-restore intent with a timeout (Codex round 7) The generation fence alone didn't self-resolve every "superseded" case: a Back immediately reversed by Forward within TASK-2166's ~140ms pane-mint settle window can coalesce to a net no-op — neither itemSlug nor loadGeneration ever change — leaving pendingBackFocus armed with nothing left to ever falsify the generation check against. The next, wholly unrelated single-load navigation would then satisfy backFocusStartGen + 1 by coincidence and steal focus. Rather than chase each interleaving individually, bound the pending intent with a timer (mirroring the host's own PANE_GO_SETTLE_MS "give up waiting" pattern): if the click's own restore hasn't resolved within 600ms, disarm unconditionally. Cleared on both the normal resolve path and onDestroy. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): replace the blunt Back-focus timeout with a targeted no-op check (Codex round 8) Round 7's flat 600ms wall-clock timeout disarmed the pending restore unconditionally, which also cancelled a legitimately slow, still- in-flight restore — loadData() makes several requests, and a modestly slow connection can easily outrun a few hundred ms, stranding a keyboard user's Back press on <body> for the common case, not just the rare coalesced-no-op one. Replace it with a single check shortly after TASK-2166's ~140ms pane-mint settle window: itemSlug updates synchronously with the popstate (independent of network speed) and loadGeneration bumps synchronously at the top of loadData(), so a load that's genuinely in flight has already moved one of the two by the time the check fires. Only the coalesced-to-nothing case (Back immediately reversed by Forward) still shows both unchanged — that's the only case the check disarms. Every other pending restore, however slow, resolves normally through the existing effect. Added a dedicated regression test proving a 400ms-delayed (well past the 200ms check) but legitimate destination load still restores focus correctly. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): correct the Back-focus no-op check window against TASK-2166's mint settle (Codex round 9) An earlier comment claimed itemSlug updates synchronously with the popstate onBack triggers — true before this branch merged TASK-2166, false after: that sibling change added a provider-mint settle that deliberately coalesces EVERY popstate-driven ?item= change onto the <ItemDetail> ref prop (even a single Back press) behind a PANE_MINT_SETTLE_MS (140ms) window, so itemSlug only starts moving ~140ms plus the history.go->popstate round-trip after the click, regardless of network speed. The 200ms no-op-check window from round 8 started at click time, so any traversal taking as little as ~60ms beyond the mandatory 140ms settle could clear pendingBackFocus as a false no-op, stranding a keyboard user's Back press on <body>. Import PANE_MINT_SETTLE_MS from the authoritative source and size the check at 2x it for headroom over the popstate round-trip on top of the settle itself. Also consolidated the accumulated round-by-round comment trail into a single numbered summary plus one timing note, since the old inline diary had an outdated claim baked into it. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): drop the timer-based Back-focus no-op heuristic (Codex round 10) Three review rounds (7-9) tried progressively longer wall-clock "give up waiting" timeouts to disarm a stale pending focus-restore on a coalesced Back-then-Forward no-op. Each one got shown to be racing an ever-longer worst-case bound elsewhere in the pipeline: the mint settle, then the settle plus the popstate round-trip, then paneHistoryGo's own 500ms fallback on top of both. A fixed-duration heuristic is structurally the wrong tool for this — closing it for real needs an explicit "did this specific click's traversal resolve" signal from the host, which is new cross-component plumbing, not a hardening pass on the existing effect. Drop the timer. Keep the generation fence (timing-independent, and the part that closes the actual bug class Codex round 6 found: an unrelated navigation superseding a stalled Back pop). Document the residual gap as a known, narrow, low-severity limitation for TASK-2162 (the already-planned general "focus per hop" follow-up) instead of continuing to patch it here: worst case, focus lands on a nearby, still-sensible, keyboard-reachable pane control instead of the ideal target — not lost, not a correctness or data issue, and it requires a fairly deliberate Back-then-Forward sequence to trigger at all. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
b14953a94e |
feat(web): Phase 1 depth-aware ESC — pop one level at depth>0 (TASK-2163) (#967)
* feat(web): depth-aware ESC pops one drill level in the pane (TASK-2163) At depth>0 in the item pane's mini-browser stack, ESC now runs history.back() to pop exactly one drill level and consumes the key, instead of routing through the list-row helpers (returnFocusToList / resolvePaneReturnTarget) which are meaningless once detached. Only at depth 0 does ESC fall through to the existing two-level return-focus-to-list-then-close behavior — unchanged on both desktop and mobile. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): route depth-aware ESC through the fenced paneHistoryGo Codex review round 1: a bare history.back() never set paneGoInFlight, so repeated ESC presses (or an ESC racing a close/reset click) could queue a second traversal against stale depth and overshoot. Route the pop through the existing paneHistoryGo(-1) fence, matching every other controller history.go call site. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): ignore auto-repeat ESC keydowns in the depth-aware pop Codex review round 2: a held ESC key fires many auto-repeat keydowns, and each one settles the paneHistoryGo in-flight fence before the next arrives, so one held press could unwind several drill levels (even closing the pane). Gate the pop on `!e.repeat` so a held key still consumes ESC but only ever pops the level from the initial physical press. Adds a Playwright test that dispatches synthetic repeat:true keydowns and asserts no further level pops. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): hoist the ESC repeat-guard above the depth-aware branch Codex review round 3: gating !e.repeat only inside the depth>0 branch left a leak at the depth 1->0 boundary — the physical press pops to depth 0, then subsequent repeat:true events (now depth 0, no .item-pane focus) fell through to runTopEscape() and closed the pane within the same held key. Move the repeat check to the top of the ESC chain so every repeat is a pure no-op regardless of what the initial press already changed. Adds a boundary-crossing Playwright test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): detect an open dialog/BottomSheet by existence, not focus Codex review (2nd pass): the ESC handler's dialog guard checked whether the EVENT TARGET was inside a dialog/[role=dialog], but BottomSheet/DockedSheet don't move focus into themselves on open — so Quick Actions / Move To on mobile left document.activeElement on the trigger button back inside .item-pane. ESC then fell through this guard, popped/closed the pane underneath, AND the sheet's own independent window listener also closed it — two layers from one press. Switch to an existence check (`dialog[open], [role="dialog"]`) so it detects an open sheet regardless of focus. `dialog[open]` (not bare `dialog`) because Modal.svelte's native <dialog> is always mounted and toggled via showModal()/close(), so a bare existence check would false-positive on any page with an idle Modal instance. Adds a mobile Playwright regression test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |