Commit Graph

45 Commits

Author SHA1 Message Date
xarmian cccb9d5858 fix(attachments): bound strip buffers and surface load failures (TASK-2418)
MAX_FETCH was documented as a cap but only ever reached the fetch `limit`:
the upload path prepended unconditionally and `pendingUploads` was itself
unbounded, so a long paste session grew the in-memory list — and the
lightbox set it feeds — without limit. Every growth path now runs through
`capped()`: the load-time merge, the upload event, the pending buffer that
rides on top of the merge, and the failed-delete rollback (PLAN-2392
DR-11).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit 10a5ae2271.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Codex review findings on 92f8a6e2 / 5bd799aa.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(web): pane tabs use automatic activation on arrow nav (Codex — roving tabindex must follow focus; activation is free on display-toggled panels)
2026-07-24 19:30:00 -04:00
xarmian 01a94d93a8 feat(web): Menu/MenuItem primitive — 3 menus migrated, escape-stack + portal + pointerdown dismissal (TASK-2292) (#1022)
* feat(web): Menu/MenuItem primitive — escape-stack ESC, portal mode, pointerdown outside-click (TASK-2292)

PLAN-2290 Phase 2, PR 4 (final primitive). New shared machinery:

- lib/components/common/Menu.svelte — anchored + portal modes (portal =
  fixed coords + flip/clamp, escapes card content-visibility containment),
  instance-scoped POINTERDOWN outside-click (structurally removes the
  BUG-2281 stopPropagation-on-rows detach workaround), ESC via the shared
  escapeStack at new priority menu=40 (one ESC closes menu before
  pane/drawer), roving keyboard nav, focus-in/focus-return, BottomSheet
  swap at 768px, --bg-raised panel skin.
- lib/components/common/MenuItem.svelte — icon/hint/danger/menuitemradio rows.
- lib/utils/clickOutside.ts + lib/utils/portalAction.ts — extracted from
  the hand-copied per-menu versions.
- app.css: --bg-raised token (dark = tertiary; light = white).

Migrated: ItemActionsMenu (portal mode, entire hand-rolled machinery
deleted), QuickActionsMenu (anchored + sheetOnMobile, EmojiPicker exemption
via exempt(), BUG-2281 workarounds removed), TopBar user menu (desktop +
mobile branches deduped into one snippet; gains aria-haspopup/expanded +
keyboard nav it never had). E2E locators updated to accessible-name form.

Documented leave-alones: TopBar workspace-overflow menu (it IS a dndzone —
conditional mount / focus-steal / pointerdown-close each break
drag-reorder; in-file comments), LaneActionsMenu drill-down +
WorkspaceSwitcher (Phase 3 / later).

Gates: svelte-check 0 errors, 488 tests, make check green; runtime-verified
via Playwright: user menu roving nav (ArrowDown x2 -> Admin), ESC closes via
stack, kebab portal placement + edge-aware rows.

* fix(web): Menu review fixes — form focus hand-off, drag suppression, scroll-close without refocus, resize close

Codex findings on #1022: (1) QuickActions create-form now receives focus
when it swaps in (the focused MenuItem unmounts on the flip); (2)
clickOutside gains suppress() and TopBar's user menu passes
isDragging||dragArmed so pill drags can't slam it shut (parity with the
old drag guard); (3) portal scroll/resize dismissal calls onclose()
directly — no trigger refocus fighting the user's scroll (parity with the
old returnFocus=false); (4) resize now also closes portal menus (stale
fixed coords).
2026-07-24 16:11:32 -04:00
xarmian 222c596a96 test(e2e): add BLOG-2289 v0.11 pane screenshot capture block (#1016)
Reusable blog-screenshot capture for the Pad v0.11 detail-pane post
(pad-web/static/blog/pad-v0-11-item-pane/01-item-pane.png). Follows the
existing BLOG-1007 / BLOG-1704 pattern; gated on PAD_BLOG_SCREENSHOTS=1
so it never runs in normal CI. Opens the docked pane via ?item=<ref> on
a seeded, content-bearing task, and logs the browser session first so the
pane's collab editor hydrates (WS auth is cookie-based) instead of
capturing a loading skeleton.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-23 16:38:55 -04:00
xarmian faf9b3734a feat(web): default new collections to Board — schema-aware (IDEA-2274, IDEA-2287) (#1015)
* feat(web): default new collections to Board view (IDEA-2274)

Board becomes the baseline default view for new collections; existing
collections keep their stored default_view (no migration).

- Frontend fallback (settingsDefaults, collection-page defaultMode,
  shareView coerce, initial viewMode) -> board
- Create/Edit collection modals default -> board
- Backend template seeds (defaults.go, templates*.go) list -> board for
  ideas/plans/docs/hiring/interviewing collections (tasks was already board)
- CLI `pad collection create` and MCP mapCollectionCreate defaults -> board
- Curated create-modal presets with deliberate list curation (Meeting
  Notes, Decisions, OKRs) intentionally left as list
- Pin the three list-keyboard-nav pane E2E tests to ?view=list

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

* fix(web): board default reaches public share page + ItemDetail fallback (Codex round 1)

Codex review found the public share route (s/[token]) derives its owner
default view via a separate `?? 'list'` fallback that bypassed the
coerceSettings change, so settings-less/legacy collections rendered List
on public share pages. Align it (and the pre-init selectedBase) to board.
Also align ItemDetail's inline CollectionSettings fallback (default_view
is unused there, but keep it consistent with settingsDefaults).

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

* fix(collections): group Contacts board by relationship, not status (Codex round 2)

Contacts has no `status` field, so defaulting it to Board grouped by the
default `status` rendered every card in a single Uncategorized lane. Set
BoardGroupBy=relationship so the board shows real lanes. All other
board-defaulted seed collections have a status field or an explicit
board_group_by (verified: Companies/Conventions/Playbooks/Docs have status).

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

* fix(web): always serialize ?view= so a List URL survives a board default (Codex round 3)

buildCollectionUrlParams treated List as the implicit URL view and omitted
it. With Board now a possible collection default (IDEA-2274), a List
selection on a board-default collection produced a URL that, when copied or
opened without the sender's localStorage, resolved back to Board. Always
serialize the view mode; add a covering unit test. Verified the pane E2E
suite (URL-equality assertions) stays green.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-23 13:33:01 -04:00
xarmian 8c710e1db9 fix(web): stop paneOverlay ref-count effect self-looping on mobile (BUG-2284) (#1009)
PR #1007 (TASK-2131) added a PaneHost `$effect` that calls
`paneOverlay.enter()`/`leave()` to inert the app-shell chrome behind the
mobile detail-pane overlay. `enter()`'s `overlayCount += 1` READS
`overlayCount` inside that tracked effect scope, so the effect took a
reactive dependency on the very signal it writes: enter() dirtied the
effect → it re-ran → enter()d again → `effect_update_depth_exceeded`.
Svelte aborts the flush, stranding the rest of the subtree's reactivity —
`paneMintForRoute` stopped recomputing, so the mobile pane (and its Back
chevron) rendered EMPTY. The E2E `pane-controller` mobile-overlay tests
caught it; #1007's own manual check verified the ARIA attributes but not
that item content still rendered.

Fix: `untrack` the count read in enter()/leave() so a write from an effect
never establishes a self-dependency (the write still notifies the layout
reader). The ref-count mutators are written from effects by design, so the
untrack belongs in the store.

Also fixes the second collision from the same #1007 change: the pane is now
`role="dialog"` on mobile, so pane-controller.spec.ts:771's `[role="dialog"]`
+ text locator matched BOTH the pane and the BottomSheet (strict-mode
violation). Target the sheet by accessible name ("Quick actions") instead —
the pane's is "Item detail".

The effect_update_depth_exceeded runaway only manifests under the real
browser scheduler (not jsdom/vitest), so the E2E overlay tests own the loop
regression; the unit tests lock the ref-count semantics.

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

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

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

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

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 16:50:23 -04:00
xarmian 34233a2a25 fix(e2e): de-flake pane j/k re-target test by opening the first row (BUG-2279) (#1003)
pane-controller.spec.ts:160 flaked ~50% (fails once, passes on retry). Root
cause via instrumentation: the test opened a NAMED seeded row and pressed `j`
(down) expecting the pane to re-target to a different item. But the two seeds
share a same-second created_at, so their list order is a non-deterministic
tie-break (BUG-2270) — the named row could land LAST, where `j` clamps at the
final index (Math.min(idx+1, len-1)) and the cursor doesn't move. The
pane-follow then correctly finds the focused row is already the paned item and
skips (no re-target), so `openItemParam` stays put and the assertion fails.

Not a product bug — the follow logic behaves correctly. Fix is test-only: open
the FIRST rendered row instead of a named one, so `j` always has a row beneath
it to move to, regardless of seed tie-break order.

Verified: :160 now 10/10 stable in isolation (was ~50-60% flaky); full
pane-controller.spec.ts 21 passed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two in-scope fixes from an independent review pass:

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-19 22:30:22 -04:00
xarmian a0bb31d33b feat(web): mount the detail pane on the full-page item host (TASK-2174) (#980)
* feat(web): mount the detail pane on the full-page item host (TASK-2174)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-19 03:46:30 -04:00
xarmian 57da084a32 feat(web): focus per hop for the detail-pane mini-browser (TASK-2162) (#972)
* feat(web): focus per hop for the detail-pane mini-browser (TASK-2162)

PLAN-2154 Architecture C / R1. Each in-pane hop (a content-link drill or
an in-pane Back) changes `?item=`, remounting ItemDetail's `{#key itemSlug}`
subtrees and destroying the just-activated link/row; `keepFocus` then drops
focus to `<body>`, where the next `j`/`k` runs list-nav and can laterally
re-target the drilled `?item=`, corrupting the stack.

Host now owns focus-per-hop: `focusPaneRegion` moves focus onto the STABLE
aria-labeled `<aside>` synchronously at each drill (`navigatePaneTo`) and
Back-button pop (`handlePaneBack`), before the imminent remount can drop it.
This covers the editor-link keyboard path (EditorLinkPopover hides its
popover — removing the focused anchor — synchronously) since it funnels
through the same `navigatePaneTo` chokepoint. A narrowed desktop `focusin`
backstop mirrors the mobile trap, re-pulling focus into the pane only when
it drops to `<body>` from within `.item-pane` (the list stays reachable).
The depth-aware ESC pop removes no focused control, so it deliberately does
not force focus into the pane — that would fight the depth-0 return-to-list.

Resolves the focus edge TASK-2164 deferred here: its component-local
armed-`pendingBackFocus` restore (which could mis-fire onto a pane control
on a coalesced Back-then-Forward no-op) is retired in favor of this
deterministic host-owned focus — the explicit host resolution signal
TASK-2164 round 10 identified as the real fix. `onBack` is now a pure notify.

Tests: focus-per-hop e2e (mouse + keyboard drill land focus in the pane
mid-load with the destination gated so only `focusPaneRegion` could have done
it; j stays inert; in-pane Back keeps focus in the pane). TASK-2164's
Back-chevron focus assertions reconciled to assert focus-in-pane (the actual
acceptance criterion) rather than a specific button. The depth-0 ESC test now
asserts the standard two-level return-to-list-then-close, which focus-per-hop
correctly restores (drilling no longer strands focus on `<body>`).

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

* fix(web): run focus-per-hop on mobile + the ESC pop (Codex round 1)

Two P1s from review:

- focusPaneRegion no-op'd on mobile while pendingBackFocus (which was NOT
  desktop-gated) had been removed, so a mobile Back/drill could strand focus
  on <body> behind the overlay if the mobile trap's focusin missed the
  removal. Focus the stable paneEl on mobile too — a synchronous belt that
  supersedes what pendingBackFocus gave the mobile Back path (j/k is already
  inert on the overlay, so this is purely the a11y "don't lose focus" belt).

- The depth-aware ESC pop skipped focusPaneRegion; if the user had Tabbed to
  a control the pop then removes (e.g. the Back chevron via the header swap),
  focus could strand on <body>. Land focus on the stable paneEl at the pop.
  It targets paneEl (never a removed control) so it needs no focusin, and it
  doesn't fight the depth-0 two-level ESC — that's a separate later press that
  correctly returns focus to the list from within the pane.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 23:30:17 -04:00
xarmian a0299321ca feat(web): in-pane Back chevron for the pane chrome (TASK-2164) (#970)
* feat(web): in-pane Back chevron for the pane chrome (TASK-2164)

Render a Back chevron in the pane header (ItemDetail's embedded-chrome
branch) whenever `page.state.paneDepth > 0` (PLAN-2154 Architecture C).
Wired to the collection host's existing fenced `paneHistoryGo(-1)` /
`paneNavInFlight()` traversal — the same mechanism the depth-aware ESC
handler (TASK-2163) uses — rather than a bare `history.back()`, so a
rapid double-click can't stack a second traversal (R14). Depth is read
reactively via SvelteKit's `page.state` accessor inside ItemDetail
itself, so a cold-loaded shared `?item=` correctly starts at depth 0
with the chevron hidden. Shared markup covers desktop and the mobile
full-screen overlay alike.

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

* fix(web): keep the Back chevron reachable in the pane's minimal header

Every drill sets loading=true, which switches ItemDetail to the
minimal embedded header (previously Close-only) — so a slow or failed
drilled item stranded the user with no way back, worst on mobile where
ESC isn't reachable. Mirror the same depth-gated chevron there (Codex
review round 1).

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

* fix(web): restore focus to the Back chevron after each pop (Codex round 2)

Clicking Back triggers a reload of the drilled-to item, which briefly
sets loading=true and swaps the loaded pane header for the minimal one
— unmounting the just-clicked, focused Back button and dropping focus
to <body>. Without this, a keyboard user popping a multi-level drill
stack loses their place after every press.

Track the intent with two flags (not one): `paneDepth` updates
synchronously with the popstate `history.go(-1)` fires, landing before
`itemSlug` changes and `loadData()` actually sets `loading = true` — a
single-flag effect can observe a stale `loading === false` on that
intermediate run and consume the flag before the real reload cycle
even starts. `backFocusSeenLoading` requires observing `loading` go
true first, so the restore only fires on the matching false after it.

Also fixes a race in the new multi-hop e2e test: `drillTo()` only
awaits `navigatePaneTo`'s synchronous portion (the `goto()` it fires
is fire-and-forget), so firing three drills back-to-back without
polling between them could read stale depth. Poll after each hop, and
assert the Back button keeps focus across presses.

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

* fix(web): key Back-chevron focus restore off itemSlug, not a loading transition (Codex round 3)

Codex round 3 found a real race in the loading-transition-based focus
restore: pressing Back again from the minimal header — while a PRIOR
item is still loading (paneNavInFlight() only fences the history.go
traversal, not the data fetch) — could let the effect latch onto that
stale, already-in-flight cycle instead of its own.

Investigating with instrumentation showed the generation-fenced
version of the fix was actually correct in isolation, but the added
console logging was itself perturbing timing enough to mask a
genuinely flaky window under heavier parallel load. Replaced the
two-flag, generation-counting design with a much simpler one: key off
itemSlug (purely URL-derived, updates synchronously with the same
popstate that stamps paneDepth, independent of fetch status) actually
changing away from the ref captured at click time, then wait for
loading to clear. No transition-observation bookkeeping needed.

Verified with 25+ consecutive passes of the new race-condition e2e
test across workers=1/2/4, including alongside the full pane suite
under system load from concurrent sibling agents.

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

* fix(web): fall back to the Close button after the terminal Back pop (Codex round 4)

The last Back press (landing at depth 0) has no Back button left to
restore focus to, so it was stranding focus on <body> with the pane
still open. Fall back to the always-present Close button — a stable,
keyboard-reachable control — rather than leaving it unhandled. Full
"focus per hop" for arbitrary content-link drills stays TASK-2162's
scope; this only closes the gap the Back button's own terminal press
opened.

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

* fix(web): bind the minimal header's Close button too (Codex round 5)

closeBtnEl (the terminal-Back-pop focus fallback added in the
previous commit) was only bound on the loaded header. If the pop's
destination is slow or fails to load, the minimal header can still be
mounted when the restore effect fires, leaving closeBtnEl undefined
and the restore a silent no-op. Bind it on both headers, mirroring
backBtnEl's existing pattern.

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

* fix(web): fence Back-chevron focus restore against a superseding navigation (Codex round 6)

Comparing only itemSlug treated ANY later navigation as "the Back pop
landed" — so clicking a different row (or hitting Forward) while the
Back destination is still loading would let that unrelated
navigation's itemSlug change satisfy the check, stealing focus into a
pane the user never asked to restore-focus on. Also left the pending
flag armed indefinitely across a quick Back->Forward that never
matched.

Fence with loadGeneration (bumped synchronously at the top of every
loadData() call, regardless of trigger): capture it at click time and
require the settled generation to be EXACTLY one more — the Back
click's own load and nothing else raced in ahead of it. A mismatch
abandons the restore instead of stealing focus, and self-resolves the
"armed indefinitely" case since loadGeneration only increases.

Verified the new regression test actually catches the bug: temporarily
disabled the guard, confirmed the test fails (focus lands on Close),
then restored it and confirmed it passes.

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

* fix(web): bound the Back-chevron focus-restore intent with a timeout (Codex round 7)

The generation fence alone didn't self-resolve every "superseded"
case: a Back immediately reversed by Forward within TASK-2166's
~140ms pane-mint settle window can coalesce to a net no-op — neither
itemSlug nor loadGeneration ever change — leaving pendingBackFocus
armed with nothing left to ever falsify the generation check against.
The next, wholly unrelated single-load navigation would then satisfy
backFocusStartGen + 1 by coincidence and steal focus.

Rather than chase each interleaving individually, bound the pending
intent with a timer (mirroring the host's own PANE_GO_SETTLE_MS "give
up waiting" pattern): if the click's own restore hasn't resolved
within 600ms, disarm unconditionally. Cleared on both the normal
resolve path and onDestroy.

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

* fix(web): replace the blunt Back-focus timeout with a targeted no-op check (Codex round 8)

Round 7's flat 600ms wall-clock timeout disarmed the pending restore
unconditionally, which also cancelled a legitimately slow, still-
in-flight restore — loadData() makes several requests, and a modestly
slow connection can easily outrun a few hundred ms, stranding a
keyboard user's Back press on <body> for the common case, not just
the rare coalesced-no-op one.

Replace it with a single check shortly after TASK-2166's ~140ms
pane-mint settle window: itemSlug updates synchronously with the
popstate (independent of network speed) and loadGeneration bumps
synchronously at the top of loadData(), so a load that's genuinely in
flight has already moved one of the two by the time the check fires.
Only the coalesced-to-nothing case (Back immediately reversed by
Forward) still shows both unchanged — that's the only case the check
disarms. Every other pending restore, however slow, resolves normally
through the existing effect.

Added a dedicated regression test proving a 400ms-delayed (well past
the 200ms check) but legitimate destination load still restores focus
correctly.

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

* fix(web): correct the Back-focus no-op check window against TASK-2166's mint settle (Codex round 9)

An earlier comment claimed itemSlug updates synchronously with the
popstate onBack triggers — true before this branch merged TASK-2166,
false after: that sibling change added a provider-mint settle that
deliberately coalesces EVERY popstate-driven ?item= change onto the
<ItemDetail> ref prop (even a single Back press) behind a
PANE_MINT_SETTLE_MS (140ms) window, so itemSlug only starts moving
~140ms plus the history.go->popstate round-trip after the click,
regardless of network speed.

The 200ms no-op-check window from round 8 started at click time, so
any traversal taking as little as ~60ms beyond the mandatory 140ms
settle could clear pendingBackFocus as a false no-op, stranding a
keyboard user's Back press on <body>. Import PANE_MINT_SETTLE_MS from
the authoritative source and size the check at 2x it for headroom
over the popstate round-trip on top of the settle itself. Also
consolidated the accumulated round-by-round comment trail into a
single numbered summary plus one timing note, since the old inline
diary had an outdated claim baked into it.

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

* fix(web): drop the timer-based Back-focus no-op heuristic (Codex round 10)

Three review rounds (7-9) tried progressively longer wall-clock "give
up waiting" timeouts to disarm a stale pending focus-restore on a
coalesced Back-then-Forward no-op. Each one got shown to be racing an
ever-longer worst-case bound elsewhere in the pipeline: the mint
settle, then the settle plus the popstate round-trip, then
paneHistoryGo's own 500ms fallback on top of both. A fixed-duration
heuristic is structurally the wrong tool for this — closing it for
real needs an explicit "did this specific click's traversal resolve"
signal from the host, which is new cross-component plumbing, not a
hardening pass on the existing effect.

Drop the timer. Keep the generation fence (timing-independent, and
the part that closes the actual bug class Codex round 6 found: an
unrelated navigation superseding a stalled Back pop). Document the
residual gap as a known, narrow, low-severity limitation for TASK-2162
(the already-planned general "focus per hop" follow-up) instead of
continuing to patch it here: worst case, focus lands on a nearby,
still-sensible, keyboard-reachable pane control instead of the ideal
target — not lost, not a correctness or data issue, and it requires a
fairly deliberate Back-then-Forward sequence to trigger at all.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 22:30:01 -04:00
xarmian b14953a94e feat(web): Phase 1 depth-aware ESC — pop one level at depth>0 (TASK-2163) (#967)
* feat(web): depth-aware ESC pops one drill level in the pane (TASK-2163)

At depth>0 in the item pane's mini-browser stack, ESC now runs
history.back() to pop exactly one drill level and consumes the key,
instead of routing through the list-row helpers (returnFocusToList /
resolvePaneReturnTarget) which are meaningless once detached. Only at
depth 0 does ESC fall through to the existing two-level
return-focus-to-list-then-close behavior — unchanged on both desktop
and mobile.

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

* fix(web): route depth-aware ESC through the fenced paneHistoryGo

Codex review round 1: a bare history.back() never set paneGoInFlight,
so repeated ESC presses (or an ESC racing a close/reset click) could
queue a second traversal against stale depth and overshoot. Route the
pop through the existing paneHistoryGo(-1) fence, matching every other
controller history.go call site.

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

* fix(web): ignore auto-repeat ESC keydowns in the depth-aware pop

Codex review round 2: a held ESC key fires many auto-repeat keydowns,
and each one settles the paneHistoryGo in-flight fence before the
next arrives, so one held press could unwind several drill levels
(even closing the pane). Gate the pop on `!e.repeat` so a held key
still consumes ESC but only ever pops the level from the initial
physical press. Adds a Playwright test that dispatches synthetic
repeat:true keydowns and asserts no further level pops.

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

* fix(web): hoist the ESC repeat-guard above the depth-aware branch

Codex review round 3: gating !e.repeat only inside the depth>0 branch
left a leak at the depth 1->0 boundary — the physical press pops to
depth 0, then subsequent repeat:true events (now depth 0, no
.item-pane focus) fell through to runTopEscape() and closed the pane
within the same held key. Move the repeat check to the top of the ESC
chain so every repeat is a pure no-op regardless of what the initial
press already changed. Adds a boundary-crossing Playwright test.

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

* fix(web): detect an open dialog/BottomSheet by existence, not focus

Codex review (2nd pass): the ESC handler's dialog guard checked
whether the EVENT TARGET was inside a dialog/[role=dialog], but
BottomSheet/DockedSheet don't move focus into themselves on open — so
Quick Actions / Move To on mobile left document.activeElement on the
trigger button back inside .item-pane. ESC then fell through this
guard, popped/closed the pane underneath, AND the sheet's own
independent window listener also closed it — two layers from one
press. Switch to an existence check (`dialog[open], [role="dialog"]`)
so it detects an open sheet regardless of focus. `dialog[open]` (not
bare `dialog`) because Modal.svelte's native <dialog> is always
mounted and toggled via showModal()/close(), so a bare existence
check would false-positive on any page with an idle Modal instance.
Adds a mobile Playwright regression test.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 19:51:43 -04:00
xarmian 62f2d0a46b feat(web): drill the pane from relationship, child, and graph-Open anchors (TASK-2159) (#966)
Intercepts the anchor-based content-link surfaces PLAN-2154 Architecture B
targets — the relationships link-target anchor, ChildItems/NestedChildren
row anchors, and ItemGraph's node "Open" anchors (not the node click,
which stays select-only) — so a plain click drills the pane in place via
onOpenTarget/fireOpenTarget, while modifier/middle-click still falls
through to the anchor's href for a full-page popout.
2026-07-18 18:59:57 -04:00
xarmian fe04de0f8f feat(web): detach at depth>0 — clear list highlight + gate pane-snap (TASK-2161) (#965)
At stamped pane depth>0 the split-pane detail is a detached mini-browser,
so the collection list must go inert. TASK-2157 already made
schedulePaneFollow bail at schedule time and re-check depth in its fired
callback, cancelled the pending follow on navigatePaneTo, and made a
detached list-row click RESET the stack (never a replace of the drilled
entry). This closes the two remaining detach gaps from PLAN-2154
Architecture C / D-detach:

- focusedItemId (the List/Board/Table row-highlight marker) now gates on
  currentPaneState().paneDepth === 0, so the highlight is CLEARED once the
  pane drills past its base and re-appears on unwind (page.state is
  reactive).
- the pane-snap $effect bails when depth>0, so it no longer snaps the
  cursor to a drilled item that may not even be in the list.

Together with the pre-existing schedulePaneFollow guards, j/k is fully
INERT at depth>0: no pane-follow and no visible highlight movement.

Tests (e2e/pane-controller.spec.ts): a highlight-cleared-and-restored test
(drill clears the row highlight, j/k can't re-introduce it, browser Back
restores it) and the R3 late-timer test (a j/k follow scheduled at depth 0
does not clobber a drill to depth>0). Full pane-controller suite (9) +
pane-a11y-focus (6) + collections vitest (157) green.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 18:41:52 -04:00
xarmian eaeeb09ab1 feat(web): pane-navigation controller — depth/ownership state machine (TASK-2157) (#962)
* feat(web): pane-navigation controller — depth/ownership state machine (TASK-2157)

Turn the collection page's split pane (PLAN-2105) into a navigable
mini-browser per PLAN-2154 Architecture A. Depth + session ownership are
stamped in SvelteKit page.state (never raw history.state), so they follow
opaque Back/Forward, survive history.go, and reconstruct on cold-load.

- New pure controller ($lib/collections/paneController.ts): planPaneDrill
  (same-ref guard + soft depth cap + ownership INHERITANCE), planLateralOpen
  (first-open mints ownership / depth-0 re-target / depth>0 stack reset),
  planPaneClose (three-way staged unwind). Fully unit-tested (26 cases).
- navigatePaneTo(target) drill added beside openItemPane; ownership created
  only by first-open, inherited by drills (cold-load base = unowned).
- Three-way ownership-aware staged close: OWNED -> go(-(depth+1)); UNOWNED
  & depth>0 -> go(-depth) then afterNavigate-latched replaceState-delete;
  UNOWNED & depth 0 -> replaceState-delete.
- R14 fence-on-continuation baked in: controllerActionSeq + a one-shot
  afterNavigate latch (seq-fenced, state-rechecked), schedulePaneFollow made
  inert at depth>0 (schedule + fired callback) and cancelled on drill/close,
  an in-flight guard so a rapid gesture can't stack a second history.go.
- depth+ownership preserved through every ?item=-preserving nav:
  updateUrlFilters, the ?graph toggle (ItemDetail), and the collection
  rename onNavigateAway (now replaceState, not push).
- navigatePaneTo exported onto the pane ItemDetail seam for TASK-2158 and
  reachable now via a localStorage-gated __padPaneController test hook.

Tests: 26 unit + 5 Playwright e2e (open/close/j-k, drill/back/same-ref,
detach j/k inertness, cold-load close, detached-row reset). Existing pane
e2e suite (13) still green.

Closes TASK-2157

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

* fix(web): rebase pane ownership on collection rename + tie latch to its popstate

Codex review of the pane controller (TASK-2157):

- P1 (rename ownership): a collection rename replaceState's /old?item=X ->
  /new?item=X, but every predecessor history entry still points at the now-
  dead OLD slug — carrying paneOwned=true forward made an owned close
  history.go back onto a 404. Ownership means "a live pre-pane entry exists
  to unwind to", which is false after a rename, so onNavigateAway now stamps
  a fresh {paneDepth:0, paneOwned:false} base on the new slug: close drops
  ?item= in place, staying on the valid new route. New e2e covers it.
- P2 (latch): gate the afterNavigate latch on nav.type==='popstate' so only
  its own history.go can consume it; a competing goto/link/form leaves it
  armed until the go settles.
- P1 (owned close discards mid-pane filter changes): documented as the
  plan-mandated R8 behavior — an explicit close is now identical to the
  browser Back that already closed the pane in PLAN-2105 (no deviation).

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

* fix(web): harden pane latch consumption + bypass draft guard on rename

Second Codex round (TASK-2157):

- Latch is no longer dropped by a competing history traversal: run() now
  RETURNS whether it reached its destination (depth collapsed to the base);
  the afterNavigate handler consumes the latch only when run() fires, so an
  unrelated browser Back/Forward during the go's in-flight window leaves the
  latch armed for its own popstate instead of clearing it against the wrong
  entry.
- Collection rename now bypasses the unsaved-draft beforeNavigate guard
  (navigatePaneAfterRename): the server-side rename already committed and the
  route component is reused across the same-route pathname change (drafts
  survive), so a "Stay" prompt could otherwise strand the user on the dead
  old slug with a stale owned stamp.

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

* fix(web): bound pane latch with a fallback timer + stronger reset correlation

Third Codex round (TASK-2157):

- P1 lockup: the "leave the latch armed until run() reaches its destination"
  rule could leave paneNavInFlight() stuck forever if the arming history.go
  was superseded (its own popstate never lands). Add a bounded fallback timer
  (PANE_LATCH_FALLBACK_MS) that best-effort-fires then UNCONDITIONALLY clears
  the latch, so the in-flight guard can never stick. clearPaneLatch() also
  tears down the timer (onDestroy + on consume).
- P2 reset correlation: the detached-open reset now requires the landing
  entry to carry ?item= (the pane base), not just depth 0 — rejecting a
  competing browser Back that landed on the pre-pane (no-?item=) entry.
- P2 rename + browser Back: documented that Back to the old-slug predecessor
  is an inherent rename-in-history limitation (past entries can't be
  rewritten), out of the controller's reach; the imperative close is fixed.

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

* fix(web): fence every controller history.go + split rename vs move nav-away

Fourth Codex round (TASK-2157):

- P1 (move vs rename): onNavigateAway is fired by ItemDetail for BOTH a
  collection rename (/user/ws/NEWSLUG?item=X — pane preserved) AND a
  cross-collection item move (/user/ws/coll/slug — full-page route, no
  ?item=). handlePaneNavigateAway now branches on whether the target keeps
  the pane (?item=): rename gets the rebase-to-unowned + draft-guard bypass;
  a move keeps the ORIGINAL guarded push so its unsaved-draft prompt still
  fires (the collection page unmounts and would lose drafts).
- P2 (duplicate close): the production owned-go close is a one-phase
  history.go(-1) that wasn't fenced, so a double-click ✕ / ESC+click could
  stack a second traversal and overshoot the pre-pane entry. Unify all
  controller traversals (owned close, cold-base close, reset) through
  paneHistoryGo(), which marks navigation in-flight (paneNavInFlight blocks a
  duplicate gesture) until the traversal's own popstate settles or a bounded
  fallback. New e2e asserts a double close lands exactly on the pre-pane URL.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 16:51:45 -04:00
xarmian 53252ee5d0 fix(web): close BUG-2129 stale-fields gap in the collection-edit switch fence (#961)
* fix(web): close BUG-2129 stale-fields gap in the collection-edit switch fence

The EditCollectionModal onupdated handler's switch fence used
`{@const keyedSlug = itemSlug}` to detect a superseded save, but Svelte 5
compiles that as a lazily-pulled derived signal — since keyedSlug was only
read inside the async callback, it always evaluated the CURRENT itemSlug,
not the value at modal-open time, making the fence a no-op. Replace it with
a genuine gen+id snapshot (pendingCollectionEditGen/-ItemId) captured
synchronously in the onmanage click handler, and use it to (a) skip
navigation/archive/close for a genuinely superseded save and (b) still
refresh the currently-shown item's fields when it's in the collection that
just migrated, closing the stale-fields clobber gap BUG-2129 describes.

Adds two Playwright regression tests: a same-collection pane-switch repro
(BUG-2129's literal scenario) and a cross-collection full-page navigation
test that mutation-testing confirms discriminates the fix (fails on the
pre-fix code with an observable wrong-page hijack).

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

* fix(web): thread editedCollectionId through EditCollectionModal to fix overlap + rename/archive gaps

Codex review of the initial fix found two real gaps: (1) the
pendingCollectionEditGen/-ItemId snapshot was a single shared mutable slot,
so opening a second collection-edit (for a different item) while an earlier
save was still in flight would overwrite it, letting the earlier save's
completion misapply the wrong item's context; (2) the superseded-but-same-
collection branch only handled the reload case, not a pending rename
(still uses the stale collSlug -> 404) or archive (silently did nothing).

Replace the parent-side snapshot with a value EditCollectionModal itself
captures synchronously (before its API call) and echoes back through
onupdated(updated, editedCollectionId). ItemDetail's fence collapses to one
check — does the currently-shown item still belong to editedCollectionId —
applied uniformly to the reload, rename-redirect, and archive-redirect
paths, so all three now behave correctly whether or not the pane switched
items mid-save.

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

* fix(web): gate the collection-edit fence on route/load settlement + fix a PATCH-timing test race

Codex round 2 found a narrower race: between a route change (collSlug/
itemSlug updating) and loadData()'s async resolution, item/collection can
transiently still hold the PREVIOUS item's data while collSlug/itemSlug
already reflect the new one. A mixed read across that window could pass
the collection-id fence using the stale `item` but build a navigation URL
from the already-updated collSlug/itemSlug, hijacking to a mismatched URL.
Gate the whole onupdated body on the existing `itemMatchesRef` invariant
(already used elsewhere in this file for the same "has loadData() caught
up" check) — bailing there is always safe since the route's own in-flight
loadData() will fetch fresh state regardless.

Also fixes a regression-test-only flake Codex flagged: the field-update
PATCH readback used waitForRequest (resolves on dispatch) instead of
waitForResponse (resolves on commit), so the immediate belt-and-suspenders
GET could race an in-flight write.

Deferred (documented in the PR, not fixed here): a further compound race
where a still-open second collection-edit modal on a different item could
be closed by an earlier, unrelated same-collection save's completion —
narrow, pre-existing-adjacent, and squarely in PLAN-2154 Phase 1's
dedicated R14 fence-sweep scope (TASK-2167) rather than this Phase 0 fix.

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

* fix(web): allow a safe corrective reload during route transitions; sync test on PATCH response

Codex round 3: gating the ENTIRE onupdated body on itemMatchesRef (added
last round) was too broad — it also suppressed the corrective void
loadData() call during a route-transition window, reopening BUG-2129's
core symptom (an in-flight load that raced the migration and lost gets no
second chance). loadData() is idempotent and gen-fenced against any
in-flight load, so calling it again is always safe, even mid-transition.
Split the behavior: the relevance + reload decision runs unconditionally
(using whatever `item` is currently known, stale or not — false positives
just cause a harmless redundant reload), while only the RISKY navigation
(rename-redirect / archive-redirect, which builds a URL from collSlug/
itemSlug) stays gated on itemMatchesRef, since those are what a mixed
stale-item/fresh-route read could misdirect (Codex round 2's finding).
collectionStore.loadCollections(wsSlug) now runs unconditionally too,
keeping the global sidebar in sync regardless of pane relevance.

Also tightens the cross-collection test: replaces a fixed 1.5s sleep
after releasing the migration with an explicit wait for the PATCH
response, so the assertion can't false-pass by running before the
(possibly buggy) navigation had a chance to fire.

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

* fix(web): fence the collection-edit callback on collSlug instead of item state

Codex round 4: gating rename/archive redirects on itemMatchesRef (added
last round) fixed the cross-collection hijack but broke the SAME-collection
case — during a same-pane item switch, collSlug never changes, so a
pending rename/archive that resolves mid-transition would skip the needed
redirect (itemMatchesRef false) and then loadData() 404s fetching the
now-stale/gone collSlug, with nothing to correct it afterward.

Replace the item-state-based fence entirely with a comparison against
`collSlug` — a plain reactive prop derived straight from the route params,
with no async lag (item/collection require a loadData() round-trip to
catch up; collSlug updates synchronously with navigation). EditCollectionModal
now also echoes back `editedCollectionSlug` (captured the same way as
editedCollectionId). `collSlug === editedCollectionSlug` is correct in
every case with no separate itemMatchesRef gate needed: unchanged for a
same-pane switch (so rename/archive/reload all still fire correctly),
and updated immediately for a cross-collection navigation (so a stale
edit's redirect correctly no-ops). This resolves the tension between
rounds 2-4 of Codex's findings with one simpler, more robust check.

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

* fix(web): guard the collection-edit callback against a destroyed instance + workspace reuse

PR-level Codex review found two more real gaps in the fence:

1. The onupdated closure can still fire after this ItemDetail instance is
   torn down entirely (pane closed, or the whole page navigated away)
   while a save was pending — JS doesn't cancel a lingering promise on
   unmount. Unguarded, that stale closure's goto()/collectionStore writes
   would visibly affect whatever the user has since navigated to. Added a
   one-way `destroyed` flag (set in onDestroy, alongside the existing
   loadGeneration bump) checked first in the callback.

2. Collection slugs are workspace-scoped, so if this component instance
   is ever reused across a workspace switch (no remount, same as the
   existing collSlug/itemSlug reuse this fence already relies on), a
   collSlug match alone can't tell two different workspaces' same-named
   collections apart. EditCollectionModal now also echoes back
   `editedWsSlug`, compared against the live `wsSlug` prop.

Also documents (not fixed here) a narrow pre-existing-adjacent edge case
Codex flagged: an embedded pane driven by a hand-crafted `?item=` whose
item lives in a different collection than the host page's collSlug won't
match this fence when its real collection is edited. This doesn't regress
anything (the prior fence was dead code and refreshed unconditionally
regardless of relevance) and is deferred to PLAN-2154 Phase 1 (TASK-2167).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-18 14:57:52 -04:00
xarmian 42e5562525 feat(web): pane accessibility & focus management (TASK-2122) (#953)
Give the collection detail pane (PLAN-2105) proper a11y + keyboard focus
handling:

- The pane <aside> is an aria-labeled ("Item detail"), tabindex=-1
  complementary landmark — a screen reader announces it and it's a
  programmatic focus target.
- Desktop split: focus STAYS on the list on open so arrow/j-k navigation
  (pane follows) is uninterrupted. Tab from the list bridges focus INTO the
  pane; a two-level ESC returns focus to the list (pane stays open), and a
  second ESC closes the pane ("ESC-to-close"). Not focus-trapped — the list
  stays reachable.
- Mobile overlay: a modal — focus moves in on open and is TRAPPED (Tab /
  Shift+Tab cycle within, never reaching the list behind it).
- On close, focus returns to the row that opened the pane.

Header controls (⤢/↗/✕) were already keyboard-operable + aria-labeled.

Pure DOM/trap math is extracted to lib/collections/paneFocus.ts (unit
tested); runtime wiring is verified by a real-browser e2e spec
(pane-a11y-focus.spec.ts).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-17 11:59:20 -04:00
xarmian 551762c074 feat(web): mobile split-pane becomes a full-screen overlay (TASK-2121) (#952)
At ≤768px the collection-page detail pane had a Phase-3 placeholder that
stacked below the list. Phase 4: make it a full-screen overlay instead —
`.item-pane` becomes `position: fixed; inset: 0; width: 100vw; z-index: 60`
with the list column left mounted BEHIND it (state + scroll preserved, the
PLAN-2105 no-remount invariant). Mirrors the graph drawer's mobile pattern.

Pane visibility stays URL-derived (`?item=`); the overlay is pure CSS keyed
off the viewport, never routed through the vestigial `uiStore.detailPanelOpen`
boolean whose mobile-entry force-close would otherwise drop the open item.
Added a reconciliation note on that matchMedia handler in ui.svelte.ts.

Adds a mobile-overlay e2e (pane-mobile-overlay.spec.ts): asserts the fixed
full-screen overlay + list-behind at ≤768px, and that `?item=` survives a
768px breakpoint crossing in both directions.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-17 00:00:56 -04:00
xarmian 5033bf6ef8 fix(web): harden split-pane collab teardown flush + regression e2e (TASK-2117) (#947)
Verify + harden provider/doc/flusher teardown across BOTH pane teardown
paths PLAN-2105's no-{#key} persistent pane introduced, plus the raw-mode
data-loss fix. The task anticipated a real defect ("if the child Editor
tears down before the parent collab $effect cleanup, capture the markdown
synchronously"); an independent Codex pass + an empirical Svelte probe
confirmed that IS the case.

Product hardening (ItemDetail.svelte):
- On unmount Svelte destroys the child <Editor> BEFORE this component's
  top-level collab $effect cleanup (top-level $effects are deferred to
  component pop(), so they tear down AFTER the template render effect that
  owns <Editor> — proven by src/lib/collab/teardownOrder/order.svelte.test.ts).
  So at teardown-flush time the editor is already destroyed; persistence
  survived only because Tiptap happens to still serve storage after
  editor.destroy() — a fragile implementation detail.
- Add `lastEditorMarkdown`, a shadow captured on every edit
  (handleContentUpdate), reset per provider instance so it can't cross
  items. readEditorMarkdown now prefers live storage only while the editor
  is alive (!isDestroyed) and falls back to the shadow otherwise — making
  the teardown flush correct-by-construction regardless of destroy order
  or Tiptap's post-destroy behavior. The earlier comment (which claimed
  the reverse ordering) is corrected.

Verified findings (unchanged, correct):
- Item-switch: collab $effect cleanup flushes → destroys provider+doc →
  nulls slots; loadData reset + editorInstance-nulling fire.
- Raw-mode: loadData flushes the raw saver (keepalive) before
  clearPending() when dirty.
- CollabProvider: per-itemID sessionStorage cursor isolates A/B; no change.

Regression e2e (pane-collab-teardown.spec.ts) — each proven to fail when
its target fix is reverted (mutation-tested), so they can't false-pass on
a timer-based backup (the risk the Codex pass flagged):
- close pane → edit reaches items.content; the collab-snapshot PATCH must
  DISPATCH within 3s of close (the teardown flush), not ~5s later (the
  idle backup).
- switch A->B in-pane → outgoing edit flushed (loadData cancels the idle,
  so the cleanup flush is the sole path), exactly one collab WS at a time,
  close → 0 WS, no A->B cross-write.
- switch mid raw-debounce → outgoing raw edit flushed (loadData cancels
  the debounce, so the keepalive flush is the sole path).
Explicit 20s waits on "Synced" cover a slow handshake + reconnect backoff.

Extracts shared login/seed scaffolding into e2e/lib/collab-helpers.ts
(reused by collab-persistence.spec.ts).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-16 21:36:51 -04:00
xarmian 348c8a3d93 test(e2e): add editor/collab persistence + convergence coverage (TASK-2058) (#920)
The editor/collab path — the subsystem with the strictest cross-component
invariants (Yjs schema-version handshake, WebSocket op-log round-trip) —
had no e2e coverage. Add two Playwright specs:

- persistence: type into the Tiptap editor, wait for the collab-snapshot
  flush that writes items.content, reload, assert the text survives. This
  drives the full chain: WS connect -> schema_version handshake -> op-log
  persist -> reload re-hydrate.
- convergence: two browser contexts open the same item; an edit in one
  appears live in the other via the relay, no reload.

Both authenticate the collab WebSocket via an in-page login (same-UA
session cookie) because a WS upgrade can't carry the suite's Bearer
header. Follow-up under TASK-733.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-11 13:21:01 -04:00
xarmian 86a952768e test: cover account delete + export contracts and Danger Zone e2e (TASK-1963) (#824)
Backend contract tests (internal/server):
- delete-account success-body: pin the exact {ok:true} envelope the UI
  consumes (the cascade/skip tests asserted only status 200).
- export happy-path: decode the artifact and assert the exact
  `attachment; filename="pad-export.json"` header, application/json type,
  and the top-level {user, workspaces} shape with inline collections/items
  (fills the TASK-508 gap; complements the BUG-1945 gate smoke test).
- TOTP paths (enabled+valid/missing/invalid, non-TOTP) were already added
  in TASK-1958 — confirmed, not duplicated.

Web e2e (web/e2e, Playwright): settings Danger Zone —
- export download (filename + success line),
- delete password branch (real register→login→delete; admin user search
  confirms the row is gone),
- delete cloud OAuth-only typed-confirm branch (session/me flags patched;
  delete transport stubbed since a self-host server requires a password),
- post-delete redirect to /login.

Delete specs run desktop-only (viewport-agnostic; avoids doubling
IP-rate-limited /auth/login + /auth/register hits that flaked the suite
under parallel load).

No product code changed.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-05 20:47:30 -04:00
xarmian 299119403c test(e2e): blog screenshot capture for BLOG-1704 (pad v0.7) (#696)
Adds a PAD_BLOG_SCREENSHOTS-gated describe block that seeds a tasks
collection, creates a public collection share link, and captures the
/s/<token> board view — the source for the v0.7 post's cover/og.
2026-06-02 16:57:18 -04:00
xarmian 954c84d0bf refactor(e2e): extract demo data into shared module (TASK-1201) (#431)
Lift the static "realistic workspace" data out of seedRealisticContent
into web/e2e/lib/demo-data.ts so it can be consumed by pad-remotion (a
sibling repo) without dragging in Playwright as a dependency. Single
source of truth for what a real-feeling Pad demo looks like.

Wire-shape compatibility is preserved exactly:
- demoPlan: same title, status, content
- demoTasks: same 7 tasks in same order, same status/priority/effort,
  same parent-to-plan linkage (now expressed via parentToPlan: boolean
  rather than carrying the plan id inline — the seeder maps it back
  to the freshly-created plan id at post time)
- demoIdeas: same 2 ideas, same fields

Behavior verified by running the gated screenshot spec:
  PAD_SCREENSHOTS=1 npx playwright test e2e/screenshots.spec.ts \
    --project=desktop-chromium
seedRealisticContent runs to completion and the dashboard / board /
list / table screenshots regenerate identically (reverted; not part
of this PR's diff).

demoConventions is also exported (4 representative entries) for the
pad-remotion ContextScene to render ghost-cards. demo-seed.ts itself
doesn't consume it — seedConventions takes caller-supplied input — but
the shape lives here so the shared data module is complete.

The companion pad-remotion file (src/data/demoItems.ts) lands as a
separate PR in PerpetualSoftware/pad-remotion. We chose copy-with-
manual-sync over a path import / symlink because pad-remotion is a
distinct git repo; a CI drift check is a possible follow-up if this
duplication starts to bite.

Parent: PLAN-1198.
2026-05-07 10:32:52 -04:00
xarmian cf04e16b5e chore(e2e): blog screenshot capture spec + shared seed helpers (TASK-1031) (#374)
Adds infrastructure for capturing Pad UI screenshots that ship inside
blog posts on getpad.dev.

* web/e2e/lib/demo-seed.ts (new) — extracts the realistic-content seed
  (1 active plan + 7 tasks + 2 ideas) from screenshots.spec.ts into a
  shared module, plus two new helpers:
    - seedConventions(fixture, request, [...])
    - activateLibraryConventions(fixture, request, titles)
  Both consumers now share the same source of truth.

* web/e2e/blog-screenshots.spec.ts (new) — gated on
  PAD_BLOG_SCREENSHOTS=1. One test.describe per blog post; each owns
  its post-specific seed and captures into ../../pad-web/static/blog/
  <slug>/. First consumer is BLOG-1007 (Conventions and Playbooks);
  subsequent posts add a describe block per shot.

* web/e2e/screenshots.spec.ts — refactored to import seedRealisticContent
  from the shared lib. No behavior change; PAD_SCREENSHOTS=1 README
  capture still passes.

Companion publish helper lives in pad-web at scripts/blog-publish.mjs.

Capture command:
  make build-go && cd web && PAD_BLOG_SCREENSHOTS=1 \
    npx playwright test blog-screenshots --project=desktop-chromium

Refs TASK-1031, unblocks BLOG-1022 / BLOG-1004 / BLOG-1003 backfill
which all want screenshots.
2026-05-02 13:05:55 -04:00
xarmian 94ebe5a83d test(screenshots): capture in dark mode (Pad's default theme) (#319)
The README screenshot capture script ran in light mode because
Playwright's headless Chromium reports prefers-color-scheme: light by
default. The Pad layout's onMount logic explicitly forces
data-theme="light" when matchMedia matches 'light' — so the captures
came out light-themed even though Pad defaults to dark when no user
preference exists.

Two effects made this misleading:
1. README screenshots showed a theme most Pad users never see by
   default. The first impression in the README didn't match the
   first impression of the running app.
2. The screenshots could not be reused in the getpad.dev marketing
   site (dark themed) without visible whiplash. TASK-918 (PLAN-911)
   needs them on the homepage; light-mode captures would have looked
   like screenshots of some other product.

Fix: pass colorScheme: 'dark' via test.use(). Chromium then reports
prefers-color-scheme: dark to the page; the layout's matchMedia check
no longer matches 'light', so it leaves the document on the default
theme — which is dark.

Also fixed a typo in the re-run instruction in the docstring (the
PAD_SCREENSHOTS=1 env var was attached to the wrong command).

Re-captured all three screenshots (dashboard, board, list) under the
new config. Docstring updated to call out the theme rationale so
future maintainers don't accidentally flip it back.
2026-04-30 13:10:51 -04:00
xarmian 8f2be1b391 feat(error): branded 404 + 500 error pages with cloud-mode chrome (TASK-906) (#315)
* feat(error): branded 404 + 500 error pages with cloud-mode chrome (TASK-906)

New web/src/routes/+error.svelte renders for any unhandled error or
unmatched route in the SvelteKit tree. Friendly status-specific titles
+ hints (404, 401/403/500 covered explicitly; falls through to a
generic "An error occurred" + framework message for anything else).

Cloud mode wraps the error in marketing chrome — AuthHeader at the
top, AuthFooter at the bottom — and adds two extra escape CTAs ("Back
to getpad.dev" + "Open docs") in addition to the always-present "Go to
home" button. So a 404 doesn't drop the user out of the brand and they
always have somewhere to go.

Self-hosted (cloudMode=false) renders a minimal centered card with
just the status code, friendly title/hint, the inline Pad wordmark
(matching the auth-card pattern), and a single "Go to home" CTA. No
getpad.dev branding imposed on operators' deployments — same gating
philosophy as TASK-902/903.

The page hydrates authStore in onMount so cloudMode resolves on first
paint, fire-and-forget; if the session fetch fails we render the
self-hosted variant — safe fallback.

Reuses AuthHeader and AuthFooter from TASK-902/903; no need for
hand-rolled chrome since those components landed first.

Parent: PLAN-900.

Test plan:
- web/npm run check — 0 errors (693 files; +1 from new page)
- web/npm run build — clean
- Svelte autofixer — clean

* fix(error): context-aware chrome for in-app vs marketing routes per Codex (round 2)

Codex caught that +error.svelte unconditionally rendered the Cloud
marketing AuthHeader/AuthFooter, but the root +layout.svelte already
wraps workspace pages in the Sidebar/TopBar/main-content app shell.
On a workspace 404 the result would be both chromes stacked: app
shell underneath plus a fixed-position marketing header floating
over the top.

Fix: branch on the same paths the root layout uses to decide whether
to render bare children. "Marketing context" (auth/share/console
paths) keeps the full Cloud-mode AuthHeader + AuthFooter treatment;
"app-shell context" (everything else, i.e. workspace pages) renders a
minimal centered block inside the existing main-content area with no
fixed-position chrome of its own.

This means the user-facing experience in each context is correct:

- /this-does-not-exist (no auth): Cloud → branded marketing 404;
  self-hosted → minimal centered card with Pad wordmark
- /login → same (auth-page family)
- /[user]/[ws]/some/missing/route: workspace shell stays intact
  with a centered "Page not found" inside the main-content area

The marketing-context list mirrors the bare-render condition in
web/src/routes/+layout.svelte (isAuthPage || isSharePage ||
isConsolePage) plus the share-page prefix.

Verification: npm run check 0 errors; web build clean.

* test(e2e): wait for workspace heading before probing topbar trigger

The bundle-roundtrip test fired a synchronous isVisible() check on
the desktop topbar trigger immediately after `domcontentloaded`. The
workspace shell is fully client-rendered (adapter-static has no SSR
for app routes), so isVisible() raced hydration: on slower CI runners
the topbar wasn't in the DOM yet, the check returned false, the test
fell through to the mobile branch, and it then timed out waiting for
an element that doesn't exist on the desktop-chromium project.

Surfaced by TASK-906 (this PR), which adds ~8 KB of root-level JS
(error page + AuthHeader/AuthFooter chunks). That extra chunk-loading
shifted the hydration race past the test's check on GitHub Actions
runners; it had been winning consistently before. Locally the test
passes in ~4s either way — the race is real but tight.

Anchor the wait on the workspace heading ("E2E Workspace") which the
dashboard route renders the moment hydration completes. Keeps the
existing desktop/mobile branching intact and adds one toBeVisible()
gate so the rest of the flow runs against a fully-hydrated UI on any
runner speed.

Verified locally: 4.0s pass after the change.
2026-04-29 23:46:07 -04:00
xarmian 272868291c test(e2e): web export → import round-trip with attachment (TASK-894) (#310)
Closes PLAN-890's regression-safety net: a Playwright spec that
seeds a source workspace with an item embedding a real PNG
attachment, drives the bundle export through the settings page,
imports it through the Create Workspace modal, and verifies via the
API that the imported workspace carries the item, the rewritten
attachment reference, and the rehydrated blob with bytes matching
the original upload.

Failure modes the spec catches:

- Export link reverts to JSON (toHaveAttribute on href + download)
- Import dispatch silently routes to the legacy JSON path (server
  would fail with gzip decode)
- Attachment id rewrite regresses (asserts the new content does
  NOT contain the OLD UUID and DOES contain a fresh UUID)
- Storage/rehydrate path corrupts bytes (assert downloaded blob
  bytes equal the original PNG)

Implementation notes:

- Bundle bytes are fetched via the auth'd `request` fixture, not
  via the browser's `<a download>` click. Playwright's download
  fork doesn't carry extraHTTPHeaders (Bearer token), so the
  click would 401. The link's href + download attribute are still
  asserted via toHaveAttribute — that pins the export-side UI
  contract.
- Modal is opened via a dual-path selector: TopBar's "+ New
  workspace" button when visible, falling back to the
  WorkspaceSwitcher dropdown's "+ New Workspace" entry. Same
  uiStore.createWorkspaceOpen flag, same modal — different chrome
  on different viewports.
- Spec is pinned to desktop-chromium. Running both projects in
  parallel trips the server's general-API rate limit
  (~10 req/sec) because seed + export + import + verify makes
  ~30 calls per worker. The flow has no viewport-specific code
  worth covering twice; one project is sufficient for round-trip
  integrity. Documented in the inline `test.skip`.
- 1x1 PNG byte sequence is the same as Go's realPNG() — same
  bytes the server-side attachment tests use, so the e2e
  exercises the same MIME-validation path.

Parent: PLAN-890.
2026-04-29 20:49:37 -04:00
xarmian 29f720c996 docs: add real README screenshots (dashboard + board views) (#257)
The README had two TODO placeholders for screenshots that have been
sitting commented-out since the project started. With the launch
imminent, fill them in.

Captures:
- docs/screenshots/dashboard.png — workspace dashboard with Active
  Work cards, Active Plans (v0.2 — Collaboration with progress),
  collection summaries, recent activity.
- docs/screenshots/board.png — tasks board view, four columns
  (Open / In-Progress / Done / Cancelled) with realistic task cards.
- docs/screenshots/list.png — list view (not currently referenced
  from the README, but kept as part of the reproducible asset set).

Reproducibility:

web/e2e/screenshots.spec.ts is a gated Playwright spec (skipped
unless PAD_SCREENSHOTS=1) that uses the existing e2e fixture
infrastructure to:
1. Spin up a fresh pad binary against a clean data dir.
2. Bootstrap an admin + workspace seeded with the startup template.
3. Add a realistic demo dataset (1 active plan, 7 tasks across
   open/in-progress/done with mixed priorities, 2 ideas).
4. Navigate + capture three views at 1440x900.

To regenerate:

  make build
  cd web && PAD_SCREENSHOTS=1 PAD_E2E_PORT=17801 \\
    npx playwright test screenshots --project=desktop-chromium

Notes:

- Table view (?view=table) was originally in scope but the URL
  parser only accepts list/board today; setting via toggle would
  require localStorage manipulation. Three screenshots already
  cover the README's needs; revisit if/when table view becomes
  URL-reachable.
- Dark/light variants were also in scope but the web UI is dark-
  mode-only at present, so the captures are dark-only.

Refs: TASK-673
2026-04-26 20:12:40 -04:00
xarmian c8601a2031 test(e2e): Playwright smoke test infrastructure + 2 dashboard tests (TASK-689) (#225)
* test(e2e): Playwright smoke test infrastructure + 2 dashboard tests (TASK-689)

Option A of TASK-689: land the test infrastructure and a minimal smoke
test on both mobile and desktop viewports. Broader flow coverage (board
view drag, item detail, comments, mobile hamburger, BottomSheet
regression guard) is tracked as TASK-733.

Infrastructure
--------------
- web/playwright.config.ts: two projects (desktop-chromium, mobile-
  chromium via Pixel 7), reporter list+html, trace/video/screenshot
  retained on failure, webServer that wipes + recreates the data dir
  then runs the pad binary. Paths anchored to the config file's
  directory so runs are cwd-invariant.
- web/e2e/global-setup.ts: bootstraps admin via POST /auth/bootstrap,
  logs in, creates the e2e workspace, mints a user-scoped API token,
  and persists the token + resolved admin username to fixture.json.
- web/e2e/fixtures.ts: extends base test so every BrowserContext
  automatically gets Authorization: Bearer <token>. Uses a token
  rather than a session cookie because sessions are User-Agent bound
  in middleware_auth.go and a node-minted session would be rejected
  by a Chromium UA.

Tests
-----
- web/e2e/dashboard.spec.ts: a logged-in user lands on the seeded
  workspace, no login form is rendered, and the workspace name
  appears on the page. Runs in both project viewports.

CI
--
- New `e2e` job in .github/workflows/ci.yml: builds web UI + binary,
  installs Playwright chromium with OS deps, runs the suite, and
  uploads the HTML report as an artifact on failure. Timeout capped
  at 10 minutes (suite itself runs in ~4s today).

Local run (in mcr.microsoft.com/playwright:v1.59.1-noble): 2 passed
in 4.1s.

Parent: PLAN-644.
Follow-up: TASK-733 for broader flow coverage (Option B in the
original ship plan).

* fix(e2e): persist server-returned workspace slug instead of the constant (TASK-689)

Addresses Codex P2 on PR #225. When Playwright's `reuseExistingServer:
true` (local dev), a re-run of globalSetup hits `POST /api/v1/workspaces`
against a DB that already has `e2e`. The server uniquifies the slug
(`e2e` → `e2e-2` → …) and returns the uniquified value, but the old
code wrote `WORKSPACE_SLUG` (the constant) to fixture.json. Tests
then navigated to /e2e-admin/e2e — which might still exist from a
previous run with stale state — instead of /e2e-admin/e2e-2, missing
regressions in freshly-seeded content.

Fix: read `slug` back from the workspace-create response and use that
when writing fixture.json. Local re-runs now always point at the
workspace this run actually created.

Parent: PLAN-644.

* fix(e2e): cross-platform webServer bootstrap via Node wrapper (TASK-689)

Addresses Codex P2 on PR #225: `rm -rf && mkdir -p && pad server start`
in webServer.command is POSIX-only. Windows contributors on cmd.exe or
PowerShell can't run `npm run test:e2e` at all — the e2e suite becomes
Linux/macOS-only, defeating the "CI parity" goal.

Fix: extract the wipe-and-exec logic into web/e2e/run-pad.mjs. Node's
fs.rmSync / mkdirSync / child_process.spawn are uniform across
platforms, and the wrapper forwards SIGTERM/SIGINT so Playwright's
teardown still cleanly kills the child on suite exit.

Local re-run in mcr.microsoft.com/playwright:v1.59.1-noble: 2 passed.

Parent: PLAN-644.
2026-04-22 22:35:08 -04:00