mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
c4d429d14c94de941ac761333cdac0a33895a05f
75 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
af24997c72 |
fix(web): re-resolve the follow target by id at fire time (BUG-2848)
Codex round 1, P2, and a real latent bug in the previous commit. Capturing the item OBJECT and reusing it 140ms later keeps a stale snapshot: a rename during the debounce changes the slug, `openItemPane` builds the URL from that slug, and the id-only existence check passes happily on the way to a dead URL. What is captured is now the IDENTITY — `targetId` — and the callback re-resolves the current row from `filteredItems` before opening it. That still follows a row that MOVED, which is the whole point of the fix, and still skips one that was DELETED, while picking up any change to the row itself. Also answering the round's second P2 in the spec rather than in code: the race is probabilistic and cannot be made deterministic without a seam in the page. The asymmetry is what makes that acceptable, and it is now written down — a round that misses the 140ms window still PASSES on a correct build, because the cursor moves, the pane follows and the intended row is where it should be. So missing costs power, not correctness; the failure mode is a false green, never a false red. Three rounds put a false green around 1 in 1700 against a build that loses the keypress 11 times in 12. pane-follow-live-list + pane-controller: 44/44 across both projects. |
||
|
|
1a76531eb3 |
fix(web): capture the pane-follow target at keypress, not when the timer fires (BUG-2848)
The list is SSE-live and the pane-follow is debounced 140ms. The callback
re-read `filteredItems[focusedIndex]` when the timer fired, which made a
keystroke depend on the list holding still for those 140ms. It does not.
The failure was silent, and that is what made it hard to see. `j` advanced
`focusedIndex`; an item arriving during the debounce shifted every index below
it, sliding the PANED item down onto that very index; the callback read it
back, found "the focused row is already the paned item", and returned through
its own guard. No cursor move, no re-target, no error — a discarded keystroke.
The target is now captured BY IDENTITY at keypress time. The callback still
re-checks pane state and that the row still exists — identity, not position, so
a row that MOVED is followed correctly and only a row that was DELETED is
skipped.
MEASURED, because the first two explanations were both wrong.
The trail's diagnosis was that an insert leaves `focusedIndex` behind so `j`
lands on the already-open row. A snap-back $effect re-syncs the cursor to the
open item on every `filteredItems` change and prevents exactly that; a pin that
waited for the row to settle passed every candidate assertion.
So the second hypothesis was that the snap-back undoes the cursor move during
the debounce, and the fix was to suppress it while a follow is in flight.
Measured: 12 of 12 failures, WORSE than the 11 of 12 baseline. The stale index
lands on the paned item by itself; the snap-back was never the culprit.
Capture-at-keypress, same harness, same sweep size:
baseline (unfixed) 11/12 lost the keypress
suppress snap-back 12/12 lost the keypress
capture target at keypress 0/12
across all three measured properties — the pane re-targeted, the cursor moved,
and the pane landed on the row that was actually below the cursor.
The new spec CAUSES the race rather than waiting for it: it seeds a row above
the cursor, then lands a second insert across the keypress inside the debounce.
Three rounds per run, because one round caught the unfixed build 11 times in 12
and three make a false green not worth reasoning about. Counterfactual against
the unfixed controller: 3 of 4 desktop runs fail with the bug's signature —
`Expected: "DOC-16"` (the intended row) versus `Received: "DOC-15"` (the row
that was already open).
It asserts three things and none is redundant: `retargeted` alone passes if the
pane wanders anywhere; `cursorMoved` alone passes if the cursor moves and the
pane ignores it; `intended` is what pins the actual contract. A fourth that
suggests itself — "the pane agrees with the focused row" — passes VACUOUSLY on
the bug, since cursor and pane are then both stuck on the opened row. It was
measured doing that and is deliberately absent.
pane-controller.spec.ts is unchanged and still green (21/21). Its intermittent
failure was this defect, not the shared-workspace pollution it was filed as —
it just could not cause the race, so it only caught it when a sibling test's
seed happened to land in the window.
|
||
|
|
58f50909af |
Merge pull request #1234 from PerpetualSoftware/feat/idea-2843-composer-quote-handle
feat(web): comment on a selection, with comments back under the item content (IDEA-2843) |
||
|
|
e92f6f235d |
fix(web): the sidebar footer's Settings label wrapped once the GitHub link joined the row (BUG-2844)
MEASURED, not eyeballed. The desktop sidebar is 260px wide, less 24px of .sidebar-inner padding, so the footer row has 235px. Four 32px controls and four 8px gaps are fixed cost; .settings-btn is the only flex:1 item and got what was left — a 75px box, 51px of content after its 12px padding. The label "⚙ Settings" needs 56.3px. Five pixels short, so the gear and the word landed on separate lines and the row grew from 33.9px to 51.7px. The five pixels arrived with the GitHub link (IDEA-2711, PR #1229): a 32px control plus a fifth gap took 40px out of a box that had about 22px of slack. space-2 -> space-1 on the row returns 16px and the Settings padding another 8px, all of which lands in the one shrinkable item: a 75px content box against 56.3px of text, a 33% margin rather than the 5% either change alone would have left. Measured after: one line box, row height back to 33.86px. ONLY THE DESKTOP ARM WAS BROKEN, which the dispatch's "every width the layout supports" is what surfaced. Below 768px the sidebar is 280px AND the collapse button is gone, so the row carries four controls in 255px and the label had 135px to itself. It measured one line box before this change and still does — and both mobile legs of the new spec PASS against the unfixed CSS, which is what makes the desktop failures mean something. .github-btn also gains `flex-shrink: 0`, which every other control in the row already had. Harmless today — its automatic minimum size equals its 32px content box — but it made the row's one shrinkable item ambiguous, and .settings-btn is meant to be that item. THE TEST IS AN E2E SPEC BECAUSE NOTHING ELSE CAN HOLD IT. jsdom performs no layout, so a vitest render of Sidebar.svelte reports identical geometry with and without the bug. It asserts LINE BOXES rather than row height: height grows for other reasons and could stay put through a wrap, while Range.getClientRects() returns one rect per line, so the count is the question itself. Counterfactual run against the reverted declarations: desktop fails "Expected: 1, Received: 2", the shrinkable-control leg fails on the extra item, mobile stays green. Gates: web unit tests 115 files / 1978 tests green; svelte-check 0 errors (the 6 warnings are pre-existing, in files this does not touch); go build and go vet clean. The full Go suite was NOT re-run locally — no Go file changed — and CI covers it; naming the narrowing rather than reporting a leg I did not run. |
||
|
|
7a7e9d669b |
fix(web): the selection toolbar is a row again (IDEA-2843)
Codex round 7. `.bubble-menu` had no layout of its own. Its buttons are themselves `display: flex`, so they are block-level and STACK — invisible while the menu held one action, wrong the moment Comment joined Extract. It also falsified the dimensions `positionMenu` clamps against, so the menu drifted over the text it points at. A row layout on the container; the expanded state opts out, since the extract form lays itself out. Layout is not observable in jsdom, so the assertion lives in the e2e: the two buttons share a row (y within 4px) and Extract sits to the right of Comment. Removing the row layout fails it in a real browser — verified. Declined, with the reason recorded in the code: "Comments 1+" can appear when the only unfetched entries are activity or versions. `+` reads as a LOWER BOUND, and a lower bound of 1 over exactly one comment is true. Knowing whether more comments exist means fetching the rest of the feed, so the alternative trades a true imprecise count for a confident wrong one. Gates: 119 files / 2005 unit tests, svelte-check 0 errors, e2e 2/2 on the selection spec against a rebuilt binary. Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo |
||
|
|
346a5c92a9 |
feat(web): Comment action on the selection toolbar, quoting into the composer (IDEA-2843)
GitHub #1228. Selecting a passage in an item's content now offers Comment beside Extract; it quotes the selection as a markdown blockquote into the comment composer under the content, appending after a blank line so an in-progress draft survives. The selection is NOT consumed — unlike Extract, which replaces it with a wiki-link — so a reader can quote the same passage twice or keep reading. - toBlockquote() prefixes EVERY line including blank ones. An unprefixed blank line ends a blockquote in markdown, so quoting two paragraphs without it silently drops the second out of the quote and leaves it looking like the commenter's own words. - The action renders when the host supplies `onComment`. A composer to quote into IS the capability; a flag that is always true beside a callback that is always supplied would be two ways to say one thing. - The button's accessible name is "Comment on selection". The composer's submit button is also named "Comment", and two identically-named buttons with different effects is a real ambiguity for name-based navigation — found by the first end-to-end run failing on a locator, not on behaviour. A NEGATIVE result, measured and kept. The action was briefly gated peek-independently, reasoning that a peeking master keeps a live composer (BUG-2263) but could not act on a selection. That state does not exist: a drag-selection in a peeking master RE-ACTIVATES it (focus-follows-editing, PLAN-2179 DR-2), so a selection and a frozen master never coexist. The gate is back on `mutationsEnabled`, and e2e/selection-comment-peek.spec.ts asserts the re-activation so a future change that makes selections survive the freeze turns red there instead of quietly reopening the question. Gates: 119 files / 1998 unit tests, svelte-check 0 errors, and 37 e2e in desktop-chromium — the 2 new ones plus the 35 in the four specs the comment relocation touched, run against a binary built from this tree. Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo |
||
|
|
7aef246dcd |
feat(web): comments move under the item content; Activity keeps changes (IDEA-2843)
GitHub #1228. Reviewing an agent-written doc meant many small comments, and every one cost a trip to the Activity tab and back. TASK-2294's own spec put an activity preview on the Details panel; it never shipped, and the comments being tab-only is the half that was left. Dave ruled the full move. One component cannot render in two DOM locations, so ItemTimeline stays the SINGLE owner — one fetch, one SSE subscription, one composer — mounted under the content on Details rendering comments, and mirrors its feed out through a new bindable `feed` prop. The Activity and Versions panels render that same feed through a second TimelineEntryList. - The mirror publishes the WHOLE feed, not the owner's rendered slice. The owner renders comments only, so publishing `visibleEntries` would leave both tabs permanently empty with nothing to report. Tested, and the one-word mutation fails it. - `loadMore` rides in the mirror: pagination is a property of the ONE feed, and a tab that can show older entries but not ask for them is a dead end. - The kind partition is three shared constants with an exhaustiveness check, not literals at the mount sites. A kind in none of them renders NOWHERE — which is how note/decision shipped invisible the first time (BUG-2301). Adding a kind to TimelineEntry without routing it is now a build error or a failing test rather than a silent hole. - The comments section carries its own {#key itemSlug}: it left the block that used to provide that remount, and dropping the guard would have been invisible. It wraps only the timeline — the collab editor must never be keyed. Five e2e specs asserted comments behind the Activity tab and are updated. attachment-lifecycle's tab round-trip is preserved deliberately: its claim is that the panel is CSS-hidden rather than unmounted, so it now goes out to Activity and BACK rather than asserting against a hidden panel. Gates: 117 files / 1987 tests pass, svelte-check 0 errors. Both new properties have negative controls — publishing the rendered slice fails 1, unrouting a kind fails 2. Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo |
||
|
|
7f640a9c40 |
feat(attachments): render markdown and plain-text attachments in the viewer
The arm itself, plus the render seam and the browser proof. `renderMarkdownDocument` is a third thin wrapper in FRONT of the shared `marked` pipeline, following `renderMarkedWithAttachments`'s precedent rather than standing up a second renderer. It omits two things deliberately. No wiki-link resolution: an attached `.md` was authored elsewhere, so resolving its `[[brackets]]` against whatever workspace is showing it would silently retarget a foreign document's links at local items — BUG-2830's hazard entered through the front door. No attachment context, and it CLEARS the module-level context for the duration rather than merely leaving it alone: a nested call from a resolver or a `missing` hook that itself renders markdown would otherwise inherit the outer document's workspace and resolver. Save, clear, restore, mirroring the sibling wrapper. Sanitization is inherited, not re-derived, so an attached document is governed by the same allowlist as item content. Plain text does NOT go through the pipeline. A `.txt` renders in a `<pre>` as text, because interpreting a plain-text file's asterisks as formatting would misrepresent its content; Svelte escapes it, so that path emits no HTML at all. THE FALLBACK ARM'S CONDITION CHANGED, and this is the part to check hardest. It read `shownRenderer !== 'raster-image'`, which was correct while the union had one member and would have drawn "No preview available" over every text document the moment it had two. It is now `=== null` — the registry's actual "no renderer claims this" answer — which says what it means and stays correct when 'pdf' lands. INTERACTION, all of it found by review or re-reading rather than by the unit suite: - The wheel handler consumed every wheel before its exclusions, so the card could never scroll. The text exclusion returns BEFORE `preventDefault`, the opposite of every other exclusion there: the others want the wheel swallowed, this one wants it delivered. Scroll chaining to the inert page is stopped by `overscroll-behavior: contain`, so the guarantee the `preventDefault` provided is kept. - The full-bleed layer took `pointer-events: auto`, so a click on the empty area targeted it and backdrop-close was broken for this arm alone. The layer is inert; the CARD is the interactive surface. - `touch-action` INTERSECTS down the ancestor chain, so the card's `pan-y` could never override the stage's `none` and a phone could not scroll at all. The stage gives up its pan claim on this arm only. - The card had no tab stop. The viewer's arrow keys are its own next/previous navigation, so a keyboard-only user could open a document and never reach past its first screen. `tabindex="0"` plus `role="document"` and the filename label; the linter warning is suppressed narrowly with its reason at the site. - The focus-handoff effect tracked `loader.phase` — the IMAGE loader, which this arm disposes, so it never changes there. This is the only arm whose CONTENT is focusable, so a reload unmounted a focused link and stranded focus outside the modal. - `resolvedSize` and `revalidateToken` ride the load key SCOPED to the text arm. The key is shared, so an unconditional append re-ran the effect for the raster arm too and restarted image loads. The token is there because a parent RESTORE drives the image loader through the metadata probe's answer but cannot see a failed text GET's `error` phase — without it, a preview that 404'd while archived stayed permanently errored after the restore that fixed it. E2E, because three of these guarantees are CSS mechanisms and the jsdom suite injects no component styles — `getComputedStyle` there returns the engine default for every element, so two assertions written for them could not fail and were deleted rather than banked as coverage. `web/e2e/attachment-text-preview.spec.ts` covers the render, backdrop close, scrolling with no leak to the surface behind, and selection. Its FIRST RUN is what caught the feature rendering raw source, which the whole green unit suite could not see. CONVE-23 sweep on the prose this falsified: the ADMISSION vs THE ARM block enumerated 'raster-image loads bytes / null is no-bytes' as a two-way split, and the fallback arm described itself as "an entry the viewer cannot draw as an image". Both rewritten, plus a paragraph on why two byte-loading arms leave the no-bytes invariant unchanged in kind. `.pad-e2e-*/` is gitignored: a shared checkout runs concurrent suites, so each seat points `PAD_E2E_DATA_DIR` at its own, and those hold a generated encryption key and a multi-MB WAL. `item-attachment-strip.spec.ts` changes here because it is a CONSEQUENCE of this arm, not a separate concern. Two of its tests uploaded a `text/plain` file and asserted the viewer showed "No preview available" over it — true when written, false by design once text previews. CI caught them; my sweep had not, because I swept the unit tests and stated that boundary nowhere, which reads identically to a complete sweep (CONVE-18's amended half). The fix keeps each test's SUBJECT — both are producer→host wiring tests whose named subject is the fallback arm — and moves the vehicle to PDF, which keeps every property the fixture was chosen for while remaining unclaimed by any renderer. It carries a note saying it will go red again when PLAN-2393 builds the `'pdf'` slot, and that the red is the design: pick the next unclaimed type, never weaken the assertion. Closes #1169 |
||
|
|
cc26288794 |
fix(web): share pages render attachment refs as honest placeholders (BUG-2389) (#1135)
The public share route (/s/{token}) rendered item content with a bare
marked() call, so pad-attachment: references fell through as broken
<img src="pad-attachment:..."> tags and dead links. Two halves:
1. CommentThread.svelte is deleted outright — grep proved it was
unmounted dead code (its only reference was a prose mention in
ItemDetail.svelte), so its half of the bug resolves by deletion
rather than by fixing a component nothing renders.
2. The share route now renders through a new opt-in wrapper,
renderMarkedWithAttachments(), which threads an AttachmentRenderContext
into the existing marked renderer hooks. With a null resolver and the
new renderAttachmentUnavailable() placeholder, every ref becomes an
honest "Attachments aren't available on shared pages yet" chip —
deliberately NOT the "missing or has been deleted" wording, because
the attachment exists; the share surface just cannot serve its bytes.
Sanitization is unchanged: the wrapper returns unsanitized HTML and
the share page keeps its single DOMPurify pass.
The `missing` hook is a parameter (default: renderAttachmentMissing) so
authed surfaces keep their existing wording, and the wrapper clears the
module context in a finally block so bare marked() callers are
unaffected (pinned by test).
The token-scoped byte endpoint that would serve real images on share
pages (2b) is deliberately NOT built here — it adds a new
unauthenticated ACL surface and is tracked separately pending approval.
A real resolver through the same wrapper is the plug-in point (pinned
by test).
Tests: markdown.shareAttachments.test.ts (6 unit legs incl. bare-marked
opt-in control and context-clearing) and
bug-2389-share-attachment-placeholder.spec.ts (e2e: real upload → item
ref → item share link → anonymous visit; verified failing on the
pre-fix build).
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
|
||
|
|
2521e3e1c7 |
fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610) (#1132)
* fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610)
In split view the item pane is an overflow-y:auto scroll container,
which computes overflow-x:auto too — the quick-actions (⚡) and ⋯
menus were ANCHORED panels inside it, and a right-aligned panel
opening from the pane's action bar extends left past the pane's edge,
so the container clipped it mid-text (Dave's screenshots: 'age
actions' for 'Manage actions', truncated tagline).
Both menus now use the Menu component's portal mode — built precisely
to escape overflow containment (fixed coords portaled to <body>,
viewport clamping, flip-when-cramped, scroll dismissal), and already
the mode of every board-card menu. Widths cover each menu's content
(QA: the 230px qa-body min-width + chrome; ⋯: the longest row).
Regression e2e uses a PAINT-level oracle — clipping doesn't shrink
getBoundingClientRect, so elementFromPoint just inside the panel's
left edge must resolve to the panel; verified failing on the anchored
control build with the exact reported symptom, plus a geometry
precondition so the probe can't pass vacuously. Existing e2e + unit
lookups that scoped menu rows under .item-pane / the master column
are page-scoped now (the portaled panel lives in <body>; only one
menu is ever open, and the scoped trigger click is what ties it to
its column).
* fixup: scope portal scroll-dismiss to anchor-moving containers — any-scroll dismissal closed pane menus under live SSE churn (found via parallel e2e instability vs a clean control build)
* fixup: codex round 1 — route nav-key bail includes portaled [role=menu] (pre-existing leak for board menus too), stopPropagation on handled menu keys, exempt-aware scroll dismiss, per-menu e2e geometry preconditions
* fixup: page-scope the two graph-drawer menu lookups in pane-content-link-anchors (codex round 2)
|
||
|
|
54526c5b33 |
fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602) (#1128)
* fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602) Seven sites assigned ItemDetail's collection snapshot under fences that ordered STARTS, and loadData's cross-collection escape hatch admitted any generation-stale write that fetched a different collection — so a loadData continuation spanning a cross-collection MOVE restored the SOURCE collection over the freshly adopted TARGET (the live item, itemGen-fenced, kept the move: the pane rendered the item against the wrong collection's schema). All writes now route through adoptCollection, backed by the pure shouldAdoptCollection decision: (1) a snapshot disagreeing with the LIVE item's collection_id is vetoed regardless of freshness — id, not slug, so renames still apply and a reused slug can't satisfy it (this also closes a latent foreign-write in the SSE collection_updated refresh, which fetched by slug); (2) same-collection refreshes keep newest-started-wins; (3) the legitimate cross-collection correction the old hatch existed for still lands when the live item agrees. On the veto path, an embedded pane whose collection was still null (fresh mount — refreshCollectionIfMoved's !collection guard skips there too) converges on the live item's collection instead of being left schema-less (adoptOrConvergeToLiveCollection); non-embedded masters stay route-authoritative per the existing policy. e2e reproduces the filed race deterministically (route-hold on the realColl fetch, API move mid-hold, release): the control build renders the moved item against SrcMarkerField's schema verbatim; the fixed build converges on the target's. * fixup: codex round 1 — post-converge myGen re-checks, schema-less error surfacing, pre-fetch convergeGen, hard collection_id oracle in e2e * fixup: codex round 3 — empty-string collection_id normalizes to no-anchor, else-branch schema-less surfacing, itemGen re-check before singleton claims * fixup: two stale comments (codex round 5 docs) |
||
|
|
904878522a |
fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601) (#1127)
* fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601) Two stranding layers, both from the same root: a collection rename changes the slug without touching items, so nothing item-shaped ever re-announces it. 1. ROUTE: delta-sync catch-up covers item changes only, and /changes says nothing about renames — a rename-only gap even reports caught_up — so a client that missed the collection_updated SSE (replay gap, disconnect) kept a dead route slug; slug-keyed fetches 404'd until a manual navigation. The collection route now reconciles its slug against the live collections list by STABLE collection id on every sync pass (resolveSyncRenameTarget — pure, unit-tested — wired via reconcileRouteCollectionSlug), mirroring the BUG-2272 SSE/reorder-404 heals and sharing the renameNav intent tracker. 2. DATA (discovered by this fix's own e2e, present on the LIVE SSE path too): cached localIndex rows keep the old collection_slug — rows only re-stamp when the item itself changes — so EVERY rename-healed route rendered an empty board while the sidebar counted the items. New localIndex.retagCollection re-stamps rows by stable collection id (search + IDB write-through, upsert pattern), called from the workspace layout's global collection_updated subscriber (any route, live SSE) and from the sync heal (missed SSE, where the layout subscriber also missed the event). e2e covers both paths with an aborted-SSE missed-event leg and a live SSE leg; both specs fail on the pre-fix build (verified) and the missed-SSE spec guards its own vacuity (asserts the strand before triggering the heal). * fixup: codex round 1 — foreign-snapshot gate on sync heal, pendingRetags for pre-hydration renames, layout loadCollections widened, goto-failure renameNav reset, worker-unique e2e slugs * fixup: stamp pendingRetags with recording user's identity; discard on mismatch at warm-hydrate apply (codex round 2) * fixup: heal the full-page item route's collection segment on sync pass too (codex round 3 — the bug body's own example route) * fixup: reconcileCollectionSegment switch-safety — pre-await fence, destroyed guard, identity-compared bridge cleanup (codex round 4) * fixup: compare the read-back $state proxy, not the raw literal (codex round 5) * fixup: codex round 7 — navigating guard on both healers, replaceState on the list heal, null-owner adoption for pendingRetags, in-window vacuity pins in e2e |
||
|
|
cada8777e7 |
fix(web): full-page pane host gets its own navigate-away handling (BUG-2178) (#1123)
The full-page item host reused the shared controller's handlePaneNavigateAway, whose goto targets are collection-host-shaped (the base route IS the collection page). On this host both emits abandoned the master: a pane collection-rename landed on the renamed collection's root page, and a pane item-move hard-navigated to the moved item's full page. The host now wires planFullPageNavigateAway (new pure planner in paneController.ts, next to planPaneDrill and friends): - COLLECTION RENAME (keeps-pane emit): IGNORE. Nothing the host owns is invalidated by the emit alone — if the renamed collection is the MASTER's, the master ItemDetail's own BUG-2272 SSE rename handler already gotos the new-slug URL with the full search string (?item= included), so the route self-heals with the pane intact; a foreign collection never touched the master route. The embedded pane needs no URL change either — it trusts item.collection_slug. - ITEM MOVE (no ?item=): RETARGET the pane to the moved item's slug via the existing drill machinery (navigatePaneTo), which preserves the master pathname by construction and handles depth/ownership/ focus. When ?item= already held the slug (a same-workspace move keeps slugs, so the drill same-ref-guards to a noop) the pane self-heals via the item_updated SSE refetch instead. - Malformed/pathless URLs: IGNORE — staying on the master beats navigating somewhere unparseable. (decodeURIComponent throws on malformed percent sequences; the hostile-input unit test caught that crash before it shipped.) The controller's handlePaneNavigateAway is annotated collection-host- only; every property of its spec comment is untouched for that host. Tests: - planFullPageNavigateAway unit table (both real emit shapes verbatim, encoded slugs, trailing slash, malformed/empty/foreign-origin). - E2E (pane-full-page-capstone.spec.ts): move the PANE item to another collection from the docked pane; assert the pathname never leaves the master route and ?item= retargets to the moved slug. Mutation- verified against a control binary with the old wiring: it fails with ?item= gone and the master abandoned — the reported bug, verbatim. The spec header's BUG-2178 deferred note updated to covered. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
e91c4fc261 |
fix(store): include the cursor's own second in /changes deltas (BUG-2539) (#1086)
* fix(store): include the cursor's own second in /changes deltas (BUG-2539)
items.updated_at / items.deleted_at are RFC3339 whole-second strings
(store.now()), while the /changes cursor is a unix-millisecond value —
normally the previous response's server_time. ItemsModifiedSince
formatted that cursor with the same second precision, truncating it
DOWN, then compared with a strict `>`. Every change landing in the
cursor's own second compared equal and was dropped, permanently: the
caller advances its cursor past that second and nothing reaches back.
User-visible symptom: a bulk archive ~450ms after a page seeded its
cursor left the item rendering as LIVE indefinitely — no banner, no
redirect — while the server had deleted_at set. It was never
archive-specific (updates were dropped identically); a missed update is
usually re-delivered by the next event, a missed deletion never is.
Compare inclusively against the truncated second instead. The boundary
second may be re-delivered, which every consumer of this endpoint
applies idempotently, and it is bounded to one second of changes per
sync. Sub-second storage is the other fix and is a migration, not a
one-liner: these comparisons are lexicographic on TEXT columns and
mixing precisions inverts them ("…20.451Z" sorts BEFORE "…20Z").
Verified against a live instance with four cursors all strictly earlier
than the archive in real time: two inside its second MISS, two in
earlier seconds HIT.
Tests:
- TestItemsModifiedSince_SameSecondCursor — same-second leg plus a
previous-second control. Fails 3/3 unfixed, passes 3/3 fixed; the
control passes on both.
- e2e bug-2539-sync-window — the banner must appear in the already-open
page AND follow a /changes delta that carried the deletion, so a
reload cannot satisfy it. The 450ms leg fails unfixed; 1200ms control
passes on both.
Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
* test(store,e2e): close the review gaps in the BUG-2539 counterfactuals
Codex review of
|
||
|
|
7b46894413 |
fix(e2e): silence cross-actor SSE creation toasts suite-wide (BUG-2334)
The e2e suite shares one pad instance and one workspace, so items seeded by OTHER concurrently-running specs arrive over SSE and stack "X created: ..." info toasts bottom-right — directly over bottom-right UI (the graph drawer's detail card), turning unrelated specs' clicks into a race. pane-content-link-anchors:238 paid a ~40-minute rerun tail at nearly every merge gate. The fix is a narrowly-scoped test-surface kill switch, not a retry: - `quietExternalToasts()` (toast store): reads a localStorage flag no production code ever sets; never throws whatever storage does. - The ONE call site announcing another actor's SSE work — the external `item_created` toast in the workspace layout — checks it. Toasts the page earns with its own actions are untouched, so specs still exercise the real toast surface (copy-dialog's no-force-click policy keeps its protective value). - The shared e2e fixture installs the flag on every context via `quietCrossActorToasts()`; collab-persistence's self-built contexts install it explicitly; account-delete's contexts never enter workspace routes and stay bare. - sse-toast-quiet.spec.ts pins BOTH sides: the quiet leg anchors on the layout branch's own by-uuid GET (pre-attached response log — no arm-order race; SSE-stream response gates the create; bounded settle before the negative assert), and a deliberately unflagged CONTROL context proves the product toast still fires — the real behavior cannot silently regress behind the suite-wide silence. Evidence: three consecutive full local suite runs with ZERO failures (baseline: 1-3 interception/load flakes per run); unit tests pin the helper's contract. Reviewed to fresh-angle CLEAN over four Codex rounds (vacuous-anchor, arm-order, SSE-connectedness, and self-built-context holes all found and fixed by the loop). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
84eef5dd38 |
test(viewer): CDP mobile touch-gesture proof + device checklist (TASK-2519)
PLAN-2392 phase 3d V3 (final task of the plan) — the mobile browser proof for the attachment viewer's touch gestures shipped in V2 (TASK-2518). Real touch is driven through the compositor via CDP Input.dispatchTouchEvent (a CdpTouch helper in the e2e lib), since Playwright's touchscreen is single-tap only. New web/e2e/attachment-viewer-touch.spec.ts (mobile-chromium), 9 legs: - two-finger spread/converge zoom in/out - off-centre affine anchor oracle: a moving-midpoint translate+spread keeps a known image-local point under the midpoint (sub-pixel residual; a zoom-around-centre mutant misses by ~75px, TOL=6px) - double-tap fit<->actual + single-image-tap-inert + backdrop-tap-close - 2->1 lift degrade: jump-free hand-off + survivor pan arms - '+' mid-pinch rebase (no stale-baseline snap; sampled on a tiny post-+ move) - touchCancel all-cancel teardown + next gesture arms fresh - letterbox touch never pans + letterbox tap closes - image/stage touch-action:none, backdrop auto - tap-to-load first-tap priority CDP semantics empirically pinned (not assumed): touchStart/Move carry the full active set; touchEnd names the ending point (pointerup#<id> observed); touchCancel is all-or-nothing. Emulation boundary recorded honestly in DOC-2521 (device-proof checklist): the 2->1 survivor-pan CONTINUATION can't be expressed in CDP — synthetic touch releases the survivor's implicit pointer-capture on the next move, tearing the fresh pan down (a real digitiser keeps it), so the leg proves arm+no-jump and the continuation is device-verified. Also checklisted: gesture feel/arbitration, momentum, real touchCancel, iOS Safari (no WebKit CI project), off-root release. Mutation-verified (build web+go at worktree root, fresh CI server per run): pinch handler disabled -> spread/oracle/rebase red; anchor->stage-centre -> oracle red while spread stays green (discrimination); double-tap disabled -> toggle red; degrade disabled -> 2->1 red; rebase disabled -> rebase red. Codex: 3 rounds, final CLEAN (r1 flagged a stage-settle race -> fixed, and a docs-based touchEnd objection -> refuted empirically; r2 flagged the rebase test wasn't discriminating -> sampled on a tiny move + mutation-proved). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
9d9670e5cc |
feat(viewer): touch pan, pinch, double-tap + touch-action:none (TASK-2518)
3d-V2 of PLAN-2392: the attachment viewer now owns touch. `touch-action: none` on the image and stage lets pointer handlers drive single-touch pan, two-finger pinch, and double-tap-to-toggle; the letterbox stays a native tap-to-close (the backdrop keeps `touch-action: auto`). Gesture state machine (built on V1's pointer registry): - Touch gestures arm only on a PAINTED-IMAGE hit, gated by a per-element paint generation (`paintedGen === loadToken`) so retry-loading is inert while the thumb→original upgrade stays live. The accept-gate snapshots the loader's fence inputs before `decoded()` mutates them. - Pinch composes ONE candidate at the clamped final scale (anchor-zoom around the previous midpoint + midpoint translation), clamped once; PINCH_MIN_DIST=12 with the below-min HELD-scale skip and re-entry rebase. - 1→2 promotion surrenders the pan capture (swallowing its lostpointercapture); 2→1 degrade rebases to the surviving founder; third-and-beyond touches are registry-only; per-pointer pointercancel routes degrade-vs-full-clear. - DOUBLE_TAP_MS=300 / SLOP=24, image-only, with compat-dblclick dedup; a live touch gesture is never seized by a mouse press, and a mouse pan keeps the looser bitmapPresent arm. Owed premise inversions: the sheet e2e now asserts touch-action none; the restore guard + test comments updated (the viewer owns touch via pointer events, but a touchmove is still not defaultPrevented, so the origin check remains the catch). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
885871a638 |
test(web): browser-prove attachment lifecycle completeness (TASK-2514)
The falsifiable subset jsdom can't see for PLAN-2392 phase 3c-iii — one Playwright leg per lifecycle mechanism the U1-U3 chain built, plus the fix for the U3 count test that was authored but never run. - Navigation-step (U3): a 3-image set whose viewer order is DERIVED at runtime (created_at-DESC ties are the DB's, not the upload order); the middle-navigated "arrival" is deleted via a SEPARATE API context so the process-local bus never tombstones it, its metadata is primed with a cacheable 200 in the PAGE context, and arrowing onto it after a reopen forces a no-store HEAD that 404s DESPITE the primed 200 (armed waitForResponse, causally the arrow's probe) → tombstone-advance to a distinct survivor. - Restore-revalidate (U2): on an ISOLATED workspace (the shared suite's SSE stream starves the delta-sync cursor), archive via per-item event then RESTORE via the BULK endpoint (items_bulk_updated, no item_id) — proving the prop-driven strip revalidation covers what a per-item SSE subscription would miss. Asserts no attachments.list on archive, and a one-shot route HOLDS the restore's revalidation list in flight to prove the tiles never blank DURING the fetch, not just after. - Timeline (U1): a strip-UI delete (so announceAttachmentDeleted runs on the process-local bus) reconciles a comment thumbnail img→missing live, with the document + timeline element stamped to prove no reload or remount. Also fixes attachment-surface-chrome.spec.ts's U3 count barrier: a bodyless HEAD is reported as net::ERR_ABORTED after its headers arrive, so it fires requestfailed, never requestfinished — the completion barrier now keys on the response. New e2e/lib/attachment-viewer.ts helpers: createWorkspace, createDoc, archiveItem, restoreItem, bulkItems (workspace-slug-aware), STRIP_DELETE. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
c5190d6a96 |
feat(web): revalidate attachment metadata per navigation step (TASK-2512)
Generalize T6's per-open forced-probe (`forcedNonce`) to per-(openNonce,
attachment): `forcedFor: { nonce, ids: Set }`. The opened entry AND every entry
navigated to now gets exactly one automatic `no-store` revalidation, while
arrowing BACK to an already-probed entry within the same open takes the fast
path. A cross-tab deletion of a sibling is no longer invisible when arrowing to
it. A reopen mints a fresh nonce, so the set resets and every entry re-probes.
Two semantics pin the accounting:
- COMPLETION, not dispatch (round-2 P1): a pair is recorded only when its forced
probe resolves non-stale. A probe discarded stale (arrow away before it
resolves) leaves the pair unseen, so arrow-back re-probes rather than painting
a maybe-deleted entry live off the seed.
- AUTOMATIC only (round-4 P2): a Retry-/restore-driven forced probe (the reload
path) never records the pair, keeping the two mechanisms independent — an
arrow-back after a Retry still gets its one automatic probe.
The mark is a plain-object write in the async continuation, guarded by the
existing `req.stale()` check and keyed to the pair the run dispatched for, so it
joins no tracked scope and cannot self-invalidate the effect.
Tests: this task owns the T6-era expectations its behavior change INVERTS.
- surfaceMetadata.svelte.test.ts: the two "navigation keeps the nonce → no
additional forced probe" tests now assert navigation to a fresh sibling forces
a second no-store revalidation (complete OR incomplete seed); added an
arrow-back-is-fast-path test and two new-behavior tests (delayed probe →
stale-discarded → re-probes; completed Retry does not record → arrow-back still
auto-probes), both mutation-verified to fail on the naive regressions.
- AttachmentSurfaceHost.svelte.test.ts: the arrow test inverts to "arrowing to a
fresh entry forces one no-store probe of the arrival; arrowing back does not".
- Lightbox.svelte.test.ts: corrected two tombstone-advance comments that claimed
advanced-to entries use the plain fetch (they now force per U3).
- attachment-surface-chrome.spec.ts (e2e, not runnable in this worktree): the
no-store counting test inverts — arrowing to a fresh sibling now forces one
HEAD of the arrival; final counts a:2,b:1. Kept to race-free claims only.
Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
bbb5a69219 |
fix(attachments): dock-clear the viewer nav on the mobile sheet (PLAN-2392 3c-ii)
The prev/next arrows were direct children of the fixed backdrop, centred `top: 50%` against the FULL viewport. The T5 phone sheet shortens the stage and docks meta+toolbar at the bottom, but the arrows had no sheet-scoped anchor, so on short/landscape phones they landed in or over the dock — obscured, or stealing the dock's taps. Move the two `.lightbox-nav` buttons INSIDE `.lightbox-stage`. On desktop the stage is `position: static`, so their `position: absolute` still resolves against the fixed backdrop — byte-identical full-viewport centring. In the sheet the stage is `position: relative`, so `top: 50%` re-anchors to the shortened stage box and the arrows clear the dock with no magic-number dock height. Add `pointer-events: auto` to `.lightbox-nav` (the stage is `pointer-events: none`); on desktop that was already the inherited value. Nav now trails the toolbar in DOM order (Close, toolbar, Previous, Next); accessible-name addressing keeps the trap tests green — adjusted the two order-naming assertions in the modal-contract spec. Adds a 720x400 landscape e2e leg asserting the arrows centre on the stage (not the viewport), sit clear of the dock, and stay clickable; the pre-fix DOM fails the stage-centre assertion by a dock-half. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
9c9107176c |
test(e2e): reconcile attachment e2e with the converged surface (TASK-2493)
PLAN-2392 phase 3c-ii T7 — the e2e half of the convergence (one host, one Lightbox for ANY attachment; the options-panel + image-viewer channels retired). Falsified + rewritten (the convergence changed the premise, so these were rewritten to assert the new behaviour, not deleted): - strip file-tile / editor file-chip open the role=dialog surface (no-bytes fallback arm), not a role=menu options panel - modal two-stacked-viewers -> the SUPERSEDE invariant (one host mounts at most one Lightbox by construction) - owner-4 BottomSheet source moved from the retired file-panel to the surviving strip delete-confirm menu - parity/two-host exact dialog-name -> anchored RegExp (T2b grew the accessible name to "name, type · size"); enforced in the hostile-name leg - zoom thumb->original timeline, switch-safety, and mobile deferred-load counts: filter to GET (the T6 always-revalidate-on-open no-store HEAD hits the same variant-less URL and polluted the counts) New legs: PDF/ZIP fallback integration (Open for PDF, none for ZIP); T6 no-store HEAD count (one per open, none on arrow, one on reopen); DR-14 archived-parent probe-gate + archive-while-open close; dual-host peeked addressing + un-peek; Pixel-7 sheet geometry / dock contiguity / backdrop-vs-chrome dismissal / shortened-stage zoom / file route / overlay-centring / DR-18 label reveal / native-pinch touch-action / forced-colors Canvas plate; desktop-unchanged contrast. Each of the four load-bearing behaviours was MUTATION-verified (break in source, rebuild the worktree ./pad, confirm the targeted leg FAILS, restore, confirm green): fallback admission, host event addressing, archive-close transition, and the T6 forced no-store probe. Codex-reviewed to CLEAN over five rounds. New selectors live in web/e2e/lib/attachment-viewer.ts, addressed by class or accessible name (never a bare [role="dialog"]); assertions are item-scoped / by-id / by-anchored-name to avoid the BUG-2504 unscoped-list pagination trap. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
e08901df28 |
fix(e2e): resolve imported attachment via content reference, not list page 1 (BUG-2504) (#1075)
The round-trip spec's final assertion fished the imported workspace's unscoped attachment list, which defaults to limit 50 / created_at_desc. Once sibling specs (PLAN-2392 browser proofs) grew the shared e2e workspace past one page, the seeded logo — old in the sort — fell off page 1 and the find() failed, holding main's CI red since 2026-08-06. The trigger window contained only CI-action bumps; the race was latent and runner-timing shifts made it deterministic. Resolve the rewritten pad-attachment: UUID straight from the imported item's content instead — the contract the UI actually follows — and assert filename via Content-Disposition plus a byte-for-byte download match. Immune to suite growth by construction. Claude-Session: https://claude.ai/code/session_01VxyZv1g6W6rGx7nuaGcH3i |
||
|
|
a593407a21 |
test(attachments): browser proof for the 3c-i surface chrome (TASK-2484)
DR-9's rule — the a11y and interaction work of the A-E chain is verified in a
browser or it is not verified. Desktop-chromium legs for the viewer's toolbar,
metadata header, delete flow, permission gate and gesture seams (the sheet
layout has no mobile e2e until 3c-ii).
New spec web/e2e/attachment-surface-chrome.spec.ts:
- Toolbar renders on all THREE origins (strip, timeline, body NodeView), with
Open/Download as real anchors carrying the EXACT canonical variant-less URL
(^/api/v1/workspaces/{ws}/attachments/{id}$) and the exact download filename.
- The permission gate: a peeked side withholds the delete affordance
(mutationsEnabled=false reaches it) and the active side's viewer toolbar
offers Delete.
- The delete flow: toolbar Delete → drill-down reached BY KEYBOARD with the
roving tabindex asserted (0/-1 ↔ -1/0), confirmed with Enter, the viewer
ADVANCES to the survivor (not the retired C1 close), and the deleted strip
tile disappears (bus reconciliation).
- The metadata header: name/type/size visible, a 180-char filename clipped with
a resolved text-overflow:ellipsis and the full value in title (DR-13), and the
inert-label contract proven by a DRAG on the header that does not pan a
zoomed BIG_PNG image nor dismiss the viewer.
- The gesture seams: a wheel over the toolbar zooms neither the image nor the
inert page behind it, with a control wheel over the stage that DOES zoom.
Every leg was mutation-checked against this worktree's built binary (revert the
impl line, rebuild, confirm the test fails, restore) — wheel exclusion,
peek-permission (mutationGate canEdit && !peeking), header name, delete advance,
toolbar-render, and the header pointer-exclusion. The wheel and header
mutation-checks each surfaced a false-pass that was fixed (a zoom-out clamped to
fit; a too-small image with no pan bound).
The FALLBACK arm + no-bytes invariant is a documented test.fixme: it is not
reachable through the real producers (they snapshot the viewer set at open and
filter unsafe MIME before it reaches the viewer), so it is jsdom-proven
(TASK-2476, via direct prop mutation). The peeked-side no-Delete VIEWER is
similarly jsdom-proven (TASK-2474): the content click that opens a viewer
re-activates (un-peeks) that side under the invisible-freeze model.
Two existing modal-spec trap tests were updated: the toolbar added focusable
controls, so the "last control" is derived (focusViewerLastControl) rather than
named, and the wrap is still asserted by name at both edges.
https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
872612360a |
test(attachments): browser proof for viewer zoom — desktop and mobile (TASK-2461)
Phase 3b's final task: the browser-level proof of the attachment viewer's zoom/pan/loading behaviour that jsdom structurally cannot give (no layout, no CSS, no gestures). New Playwright spec e2e/attachment-viewer-zoom.spec.ts runs on desktop-chromium and the Pixel 7 (mobile-chromium) project, with shared fixtures/helpers added to e2e/lib/attachment-viewer.ts. Desktop legs prove the RENDERED transform moves under wheel / ctrl-wheel / keyboard / double-click; the anchored point stays under the cursor; pan clamps in two legs (an in-bounds drag moves by the delta, an over-drag stops at the edge with no further movement); a press-drag-return-to-start over the backdrop does NOT dismiss while a plain backdrop click does; a click on blank stage space inside the stage box but outside the image dismisses (the letterbox is pointer-transparent); close/nav stay hit- testable and Tab still cycles at maximum zoom; enlarging the window clamps the stranded scale down to the new maximum (still zoomed, not reset); reduced-motion suppresses the animation while normal mode keeps it; and forced-colors keeps the image boundary visible. Loading legs prove the thumb->original swap (the thumb response finishes before the original is requested) and that a rapid A->B->A with a slow original leaves the live image correct (the switch-safety end-state; Chromium aborts detached img loads, so the detached-late-error fence itself stays unit-covered). The mobile leg proves the DR-5b deferred cell issues no automatic request until a real tap, then loads exactly one original. Every assertion is mutation-checked against the binary the Playwright webServer launches: each test fails when its implementation line is reverted. Selectors are class-qualified or by accessible name, never a bare [role=dialog]. Controls are addressed by accessible name; shared constants live in the lib. Also lands the DR-4 forced-colors CSS in Lightbox.svelte (deferred in the zoom tasks): a system-colour border on the image boundary (its box-shadow is stripped under forced-colors) plus explicit ButtonText borders on the controls — the contract the browser proof verifies. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
df741172fe |
fix(a11y): keep Escape consumption event-scoped so one press closes one layer (BUG-2441, TASK-2448)
`isBlockedByModal()` answers "is a viewer lease held RIGHT NOW". `Lightbox`'s
escape-stack handler calls `onClose()` synchronously, and Svelte flushes the
teardown — hence `lease.release()` — inside that call. Every `window` keydown
listener later in the SAME dispatch therefore asked against an empty stack, was
told "nothing is in front of you", and closed a second layer: one Escape closed
the viewer AND the `DockedSheet` / `BottomSheet` underneath it. The query was
right; the moment it was read was not.
Consumption is now recorded per EVENT. The viewer marks the dispatch it
consumed (`noteEscapeConsumedByViewer`) before closing, and `isBlockedByModal`
takes an optional event: a marked one blocks every later owner outright,
whatever the lease says by then. `runTopEscape(event)` forwards the driving
event to handlers so the viewer has something to mark; the stack itself never
reads it.
Keyed on the event object, NOT on `defaultPrevented` — that flag says only
"somebody handled this key", is set by controls that are not viewers, and
honouring it would change sheet behaviour with no viewer present. This marker
can only ever be set by a frontmost viewer, so on an empty lease stack it is
unreachable by construction.
DELIBERATE, NAMED BEHAVIOUR CHANGE — the FOURTH named parity exception of
PLAN-2392 phase 3a, alongside the three already recorded. `DockedSheet`,
`BottomSheet` and the `TopBar` overflow menu now decline an Escape a viewer has
already consumed. TASK-2430 shipped `DockedSheet` declining an already-
`defaultPrevented` Escape unannounced and it was reverted; this is approximately
that change made deliberately, with a stated reason, a narrower trigger and
tests. `TopBar` is not known to be broken today — its listener happens to run
before the route driver — but that is mount-order luck, not a guarantee, so it
is closed too.
EMPTY-STACK PARITY, per owner: with no viewer, the marker cannot exist, so each
touched call site reduces to exactly its previous expression. Asserted rather
than argued — `DockedSheet` and `BottomSheet` each gain an unmarked-Escape
regression beside the new blocked case, `TopBar`'s existing owner-5 e2e covers
both directions, and a `viewerBackdrop` unit test states the equivalence
directly (`isBlockedByModal(o, unmarked) === isBlockedByModal(o)`). The reverted
2430 `defaultPrevented` regression test still passes untouched.
The two `test.fail()` cases pinning BUG-2441 are now real assertions, each
extended with a second press proving the sheet keeps its own Escape rather than
going permanently deaf.
MUTATION-VERIFIED, both halves (TASK-2436's precedent):
• drop the viewer's mark → owners 3 and 4 fail: "the sheet is a LOWER layer
and must survive the press / element(s) not found", plus the new Lightbox
jsdom case ("expected spy to not be called at all, but actually been
called 1 times").
• drop the sheets' event argument → the same two e2e cases fail identically.
• drop the driver's `runTopEscape(e)` → the wiring contract fails
("expected … to match /runTopEscape\s*\(\s*e\s*\)/").
Gates: npm run check 0 errors; npm run test 1090 passed; the three viewer e2e
specs 32 passed.
|
||
|
|
228f99318b |
test(e2e): prove the viewer's modal contract in a browser (TASK-2436)
Phase 3a deleted a native `<dialog>` that `showModal()` was giving five
guarantees for free — top-layer stacking, background inertness, a focus trap,
focus restore and Escape — and hand-wrote each one. jsdom's `<dialog>` polyfill
(`src/test/setup-jsdom.ts`) only toggles attributes, so the phase's unit suites
cannot see ANY of those five: no inertness, no top layer, no `:modal`, no real
Tab traversal, no stacking. DR-9 says this is verified in a real browser or it
is not verified.
Three specs, 32 tests, each written against "what mutation would this catch
that a jsdom-equivalent implementation would survive":
attachment-viewer-modal.spec.ts — portal + MEASURED viewport geometry (a
`transform`/`contain` ancestor changes the rect, not the declaration); focus
entry; background inertness proven by injecting a focusable probe into every
body child AND by the REAL top-bar control, which can only go inert by
cascade; focus restore asserted as an ORDERING (the invoker is verified
UNFOCUSABLE while the viewer is up, so a restore-before-release could not
pass) AND on its DECLINE path, with a detached invoker — the ordinary case,
since the NodeView that opens the viewer is re-rendered on any document
change; the focus trap in BOTH directions, including the backward-wrap branch
(`nextTrapTarget` returns `last` only for Shift+first) and the single-control
viewer where first === last; `showModal()` vs `show()` vs a dialog mounted
closed and shown later, plus a native modal opened OVER the viewer winning
both Escape and Tab outright; paint order hit-tested against a 99999
body-portaled rival, with a raised-z-index control so the measurement is
provably sensitive to stacking (Chromium excludes inert subtrees from hit
testing, which would otherwise make it vacuous); Escape through BOTH real
route guards, asserting which layer closed; two stacked viewers; and the
mobile pane integration, where the pane's nested `inert` writes and the
backdrop's body-child writes are shown to be disjoint at every transition.
attachment-viewer-owners.spec.ts — all seven TASK-2430 owners, each with a
viewer-frontmost case AND an empty-stack regression: the six root shortcuts,
the collection route's navigation half, DockedSheet, BottomSheet, the TopBar
overflow menu, the sidebar edge swipe (including a gesture that STRADDLES the
viewer opening) and the co-mounted item graph.
attachment-viewer-parity.spec.ts — the finite parity matrix, four producers ×
{open, ←/→, Escape, backdrop click, close}; Enter/Space activation of inline
images including explicit `repeat: true` keydowns; Cmd/Ctrl+Enter still being
the comment editor's SUBMIT; hostile/long/bidi accessible names and RTL
geometry; the host lifecycle, driven through CLIENT-SIDE navigation with the
document verifiably still mounted (a `page.goto()` version would prove only
that unloading a document removes its DOM); and two-host isolation.
TWO KNOWN DEFECTS ARE RECORDED AS `test.fail()`, not papered over — BUG-2441.
One Escape over a DockedSheet or a BottomSheet closes BOTH that sheet and the
viewer. The sheets' `isBlockedByModal()` guards are correct; they are READ too
late. Both they and the route's escape driver are `window` keydown listeners,
and Svelte flushes the viewer's teardown synchronously inside the driver's
handler, so a sheet listener running later in the SAME dispatch sees an
already-empty lease stack. Invisible to the unit suites (one component's
handler, nothing releasing a lease mid-dispatch) and invisible to a
click-driven test — closing the same viewer with its Close button leaves the
sheet open, which is how it was isolated. The annotations are applied AFTER
setup, so a login/seed/navigation failure cannot hide behind them. The tests
assert the CONTRACT, so the day it is fixed they go red and the annotations
must come off. The TopBar overflow menu, checked the same way, is unaffected.
Documented gaps, stated rather than papered over: the paint-order rival is a
synthetic overlay at the picker's declared z-index (the real picker cannot be
co-present — opening the viewer by pointer dismisses it) and must be de-inerted
to be hit-testable; `expectBackgroundInert`'s floor is one behaviourally-proven
background child; the pane test's inert-set comparison identifies elements by
tag plus first class; and gestures under a frontmost viewer are dispatched
rather than delivered, since a real wheel or touch cannot reach a covered
element (the graph's baseline leg does use real input).
The shared fixture builds a real 200x150 PNG rather than reusing the 1x1 the
older attachment specs share: that one has a bad IDAT checksum, so thumbnail
decoding skips and the rendered `<img>` has no box — unclickable, and "not
visible" to Playwright. It also has to out-size the editor's image toolbar,
which is absolutely positioned over a small image's whole area.
Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
|
||
|
|
7cfe50d842 |
feat(a11y): defer global key and gesture owners to a frontmost viewer (TASK-2430)
Global keyboard/gesture owners now consult the shared arbitration helper
(`isBlockedByModal`) instead of acting unconditionally, so the
native-`dialog:modal` branch is enforced everywhere rather than only in the
two route files TASK-2429 rewired.
This is modal-contract work, not parity work: route, graph, pane, sidebar and
sheet behaviour deliberately changes while a viewer is frontmost. With NO
viewer and no native modal the helper returns false, so EMPTY-LEASE BEHAVIOUR
IS UNCHANGED FOR EVERY OWNER — each ships an empty-stack regression test, and
forcing any owner's guard to decline unconditionally fails one (verified per
owner). Every guard is also mutation-verified to kill at least one test in the
other direction; there is no guard left that a test cannot fail on.
Two earlier revisions of this commit broke that promise and were REVERTED:
DockedSheet declining an already-`defaultPrevented` Escape, and the overflow
menu's arrow-nav being revived past `svelte-dnd-action`'s role rewrite. Both
fire with no viewer present, both are defensible on their own merits, and both
belong in their own item rather than arriving unannounced inside an attachments
phase. The one remaining empty-lease change is named and intended: `?` no
longer closes the Keyboard Shortcuts modal from inside itself, which falls out
of the native-dialog branch this task exists to enforce (Escape and its close
button still dismiss it).
The seven owners:
1. root app-shell shortcuts (+layout) — were entirely unguarded
2. both route keydown handlers (see the asymmetry below)
3. DockedSheet — an unregistered role="dialog" Escape owner
4. BottomSheet — front layer wins over the sheet-only frontmost check
5. TopBar overflow menu — Escape + Up/Down
6. Sidebar — mobile edge-open swipe and the swipe-to-close
7. ItemGraph — wheel zoom and pan; it co-mounts with the viewer
ESCAPE IS NOT ARBITRATED ON THE TWO PANE ROUTES, deliberately. Those handlers
are the only code that runs `escapeStack`, and the VIEWER's Escape lives there
— an arbitration bail above the dispatch would return first and leave a
frontmost viewer undismissable by keyboard, reintroducing exactly the dead key
TASK-2429 fixed. What DID need arbitrating is the collection route's NAVIGATION
half (j/k, arrows, h/l, Enter, Tab), which would otherwise keep re-targeting
the list under the viewer — so the guard sits below the Escape dispatch and
above the nav switch, and both bounds are asserted. The item route, being
Escape-only, gains no arbitration guard at all (its existing `defaultPrevented`
/ text-entry / `hasForeignEscapeOwner` guards are untouched). Hoisting the
guard, dropping it, and adding one to the item route are all mutation-verified
to fail a test.
`hasForeignEscapeOwner()`'s ARIA branch becomes LEASE-AWARE, because 3b changes
its premise. It used to be right that a sheet open beneath a viewer still owned
Escape — the sheets acted unconditionally. Now they stand down, so reporting
one would leave Escape with NO owner: driver returns, sheet declines, viewer's
stack never runs. The branch now counts only sheets NOT behind the frontmost
viewer, by CONTAINMENT rather than a blanket "a lease exists": a sheet nested
INSIDE the viewer is in front of its content and does still own its Escape.
The native branch is checked first and wins outright, on both the
`dialog:modal` path and the `dialog[open]` fallback: nothing in the app guards
a native `<dialog>`, so unlike a sheet it never stood down and does still own
Escape. Applying the containment rule to it as well was tried and reverted —
the fallback cannot tell a modal from a non-modal dialog, so letting the lease
out-rank it would fire the browser's native `cancel` AND run the stack, closing
two layers on one press. The residual asymmetry that leaves (a NON-modal
`<dialog open>` beside a viewer, on an engine without `:modal`) is documented
at the branch and is unreachable here twice over: `Modal.svelte` is the only
`<dialog>` in the tree and only ever calls `showModal()`, and every engine that
ships `<dialog>` ships `:modal`.
Plus two more global Escape owners found by review sweep: the workspace graph
route and the console shell. Neither can host a viewer and neither drives the
escape stack, but the root layout mounts native dialogs on both, so one press
would cancel the dialog AND mutate the layer underneath.
Captured gestures that straddle the viewer opening are gated at START and on
the captured move/end: the graph has no `lostpointercapture` handler, so its
pan is torn down (capture released) rather than merely skipped; the pane
divider ends its resize; the sheet and sidebar swipes are abandoned. The start
gates are separately load-bearing — a gesture begun under a viewer must not
come alive when the viewer closes — and are tested as such.
Owner arguments are the ACTING SURFACE (a bound element, `e.currentTarget`, or
`null` for the app shell), never `event.target`. The four WINDOW-level call
sites — +layout, TopBar, DockedSheet, BottomSheet — each have a test that
dispatches from inside the viewer, which is the case that distinguishes the two
choices; the element-bound listeners (PaneHost's divider, Sidebar's aside,
ItemGraph's viewport) cannot receive an event originating in the viewer at all,
so there is nothing to distinguish there.
Deliberately NOT guarded: pure pointer-dismissers (clickOutside, the pickers,
board lanes, and TopBar's outside-click), which only tear down lower UI.
DEFERRED, not covered here: `svelte-dnd-action`'s global drag handlers (nine
call sites) and the editor's block-drag action own gestures whose finalize can
persist a reorder if a viewer opens mid-drag. Gating them needs a reactive
lease signal rather than a call-site guard, which is a materially larger change
than this task's contract — flagged for a follow-up item.
ItemGraph's pointerup path carries NO gate: the obvious symmetry with the move
gate is unfalsifiable — teardown is identical either way, so no test can fail on
its removal — and an unkillable guard reads as coverage without being any. The
straddle is covered by the move gate, which releases the pointer capture. The
one sequence neither gate can see (a capture-less press whose release RETARGETS
to the portaled viewer, leaving `maybeDrag` latched) is pre-existing and already
mitigated by the `buttons & 1` abort in `onPointerMove`; a test now pins that
mitigation so it cannot be removed silently.
Also in this commit:
- e2e: target the create-workspace dialog by accessible name, not a bare
`dialog` role
- test infra: `$app/navigation` mock + a localStorage shim for the jsdom
project, without which Sidebar/TopBar/PaneHost/+layout could not be
mounted at all. Both Storages are cleared before every TEST (not per
setup-file load) so the shim is deterministic under any pool config; the
trade-off — in-memory stand-ins cannot reproduce real Storage failures — is
documented at the shim. NOTE: `svelte-dnd-action`'s role rewrite
(`menu`/`menuitem` → `list`/`listitem`) leaves TopBar's roving-focus query
matching nothing in the browser. Pre-existing, left as-is, documented at
both the query and its test.
The native top-layer leg of the precedence rule is not asserted against a real
engine (jsdom has no top layer and throws on `:modal`; the suite emulates it);
end-to-end proof belongs to TASK-2436's Playwright suite.
Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
|
||
|
|
a40049c214 |
fix(attachments): meet criteria 1, 3 and 5 as written
Final round, scoped to this phase's six acceptance criteria rather than to whatever the diff suggested — the previous rounds had started finding issues in adjacent surfaces, which is the signal that the core had converged and the review was expanding. Three of the six were not actually met: 3. Download returned `undefined` for a nameless row, which drops the attribute entirely and turns Download back into a navigation. The server sends an inline disposition for most types, so the file would have OPENED instead of saving — the precise regression this action exists to prevent, reachable whenever a chip's metadata is partial. The attribute is now always present; empty just lets the browser name the file. 5. "A peeked pane offers no delete" was implemented as a visible, disabled Delete row. The strip hides its delete control outright in the same state, so one object was offering two different affordances for one permission depending on which surface you met it through. Delete is absent now, with `enabled` and `run` still gating behind it. That change surfaced a conflation in the panel: its action context ANDed permission with `missing`, so once the descriptor used it to decide EXISTENCE, a gone row lost Delete while Open and Download stayed present-and-disabled beside it. Permission and reachability are separate questions again — the render site already disables every action while missing. 1. The editor-chip half of "the same panel wherever you meet an attachment" had no end-to-end coverage: the chip's tests mock the bus and the host's inject events directly, so nothing exercised a real NodeView reaching a real host. Covered now by a browser test that drops a text file and clicks the resulting chip. make check exit 0, 745 unit tests, 6/6 e2e locally. |
||
|
|
b253a2be6f |
test(attachments): cover the wiring the unit suites structurally cannot
Final review round 3, on the test suite as a deliverable. The panel had no producer-to-host test: the strip's tests mock the event bus, the panel host's tests emit on it directly, and between them a broken hostToken thread through ItemDetail would have passed everything. Verified by breaking that thread — the new browser test fails, the whole unit suite stays green. It is also the only place DR-12's "activates exactly once per key press" can be demonstrated at all: jsdom does not synthesise a button's activation click, so the unit test could only ever prove the narrower "no hand-rolled handler races the UA click". That test is renamed to claim exactly that, with a pointer to where the real one lives. Also folds the workspace into the host-address reader. It was captured once in the Tiptap options while the URL builder stayed live, so a mounted chip surviving a pane workspace switch would probe under the PREVIOUS workspace's key — a cross-workspace answer, cached under the wrong key. Same staleness class as the item id, one axis over. This incidentally makes the image extension's `address` option load-bearing rather than the dead plumbing the review flagged: its probes read the live workspace through it now. isAddressable deliberately takes only the two ROUTING fields — the workspace rides along for cache keying and says nothing about whether an event can find its host. |
||
|
|
c4189b1dc6 |
fix(attachments): one in-app delete confirmation, everywhere (TASK-2425)
The strip's hover `×` raised a browser-native `window.confirm` while the options panel — and the rest of the item UI — drilled down to an in-app sub-view. Two confirmation styles for one object is exactly what DR-18 exists to prevent, and the settings Storage tab's row Delete was on a native `confirm()` too. All three now render one shared `AttachmentDeleteConfirm`: prompt as `role="presentation"` carrying an id, `aria-describedby` back-reference from the destructive row, Cancel FIRST so the focus handoff can never land Enter on Delete, destructive row last. It renders rows only — each surface supplies its own `Menu`, so ESC ordering, outside-click, portal placement, focus return and the mobile sheet swap stay the app's existing behaviours rather than a second implementation. Both warning arms carry through verbatim, from one shared builder: the referenced arm and the hedged one, which stays hedged because the check can only ever speak for the item it has. The Storage tab keeps its own wording (the GC grace period) — a reference check has no meaning in a workspace-wide list — but shares the shape. The delete REQUEST paths are untouched: same entry paint fence, same `viewFence.begin()`, same optimistic removal and single-row rollback, same 404-is-authoritative arm, same `announceAttachmentDeleted`. One addition each: `window.confirm` blocked the thread, so the entry fence was still true by definition when it returned — an in-app confirmation does not, so the fence is re-checked where the request is actually sent, and an open confirmation is abandoned when the view changes under it or another surface deletes the row. Also fixes an unhandled rejection the suite surfaced: `Menu` places itself in a `tick().then()` that can run after its block is torn down, so every prop expression reading the pending state needs `?.`. Tests: the ~18 strip tests (and 4 Storage tab tests) that spied on `window.confirm` now drive the real rows; every message-arm, fence and rollback assertion is preserved. New coverage for the confirmation's shape, Cancel's focus return, the confirm-time fence, and abandonment on switch / external delete. The e2e strip spec drops its `dialog` handler and pins the 24×24 target size in a real browser. Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC |
||
|
|
cccb9d5858 |
fix(attachments): bound strip buffers and surface load failures (TASK-2418)
MAX_FETCH was documented as a cap but only ever reached the fetch `limit`: the upload path prepended unconditionally and `pendingUploads` was itself unbounded, so a long paste session grew the in-memory list — and the lightbox set it feeds — without limit. Every growth path now runs through `capped()`: the load-time merge, the upload event, the pending buffer that rides on top of the merge, and the failed-delete rollback (PLAN-2392 DR-11). A failed list fetch used to render as "no attachments", making a broken strip and an empty one indistinguishable. It now shows a compact "Couldn't load attachments · Retry", and Retry invalidates the shared per-attachment HEAD-metadata cache before refetching — that cache latches `null` on failure for the page lifetime, so a naive retry would replay the cached failure on every surface that probed during the same outage (DR-10). Retry also keeps what the failure preserved (optimistic uploads, deletion tombstones) rather than clearing like an item switch. A delayed loading row makes loading, empty and failed three distinguishable states without flashing a block above the editor on the common un-attached item; empty still renders no section at all (DR-18). The header shows the true count, `50+` once rows exist past the bound, and the overflow continuation is now item-scoped: "View all (N)" links to `?attachment_item=<id>#storage`, the settings route passes it through and owns clearing it, and StorageTab seeds its existing `item_id` filter from it with a visible, clearable scope chip plus its own loading/error states. The count is tracked as a delta beyond the strip, anchored on the server's `total` and corrected for rows the page reported as deleted and for uploads the page predates — so local deletes and uploads keep it honest. Two residuals are deliberate and commented at the call site: a deletion of a row PAST the bound can't be attributed (the bus is workspace-wide) and may overstate by one until the next load, and uploads shed by the pending buffer during a single in-flight request are uncounted because counting them double-counts the ordinary case. The test asserting a failed fetch shows no visible error is deliberately falsified and replaced. Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC |
||
|
|
a99e71afe4 |
feat(web): refresh the attachment strip on upload (TASK-2385)
A file dropped or pasted into the editor now appears in the item attachment strip immediately, instead of waiting for the next load of the item (PLAN-2382 phase 3). The task specified threading an onAttachmentUploaded callback down through both <Editor> branches. Implemented on the attachment event bus instead: TASK-2384 already introduced one for deletions, the strip already subscribes to it, and reusing it avoids prop-drilling a second channel through a component that has no other reason to know about the strip. The deletion module is renamed $lib/attachments/events.ts to cover both directions. The upload closure captures the item id at upload START -- the promise outlives an A->B switch even though <Editor> is keyed on item.id, and AttachmentUploadResult carries no item_id, so that is the only point where the association is known. Uploads without item context are not announced: the server leaves item_id NULL for those, so an optimistic tile would vanish on refresh. The strip's internal row type is narrowed to what a tile renders. The upload response has no storage_key / content_hash / created_at, and fabricating them to satisfy AttachmentListItem would be worse than not modelling columns nothing displays. Also adds the browser-level coverage this plan was missing. The component suite mounts the strip directly, so it passes even if the ItemDetail mount is deleted or mis-wired; e2e/item-attachment-strip.spec.ts pins in a real browser: the strip is mounted and shows only the current item across an A->B switch, a dropped file appears with no refetch, delete removes the tile and degrades the inline image to the missing placeholder, the delete control genuinely takes keyboard focus (jsdom applies no scoped CSS, so a regression to visibility:hidden is invisible there), and a peeking master shows tiles with NO delete control. That last one was mutation-verified: passing canEdit instead of mutationsEnabled fails it. Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC |
||
|
|
e97a13eb73 |
fix(web): untrack the copy dialog's open/close reset (BUG-2379)
Reopening the copy dialog after changing the destination workspace wedged Svelte's effect scheduler. The dialog silently failed to appear and every other control on the item pane died with it — the ⋯ menu stopped opening, the split view could not be closed, the selected item could not be changed. No console error, because a production build reports none. `$effect.pre` called `resetForOpen()` inside its tracked scope. `resetForOpen` writes `destWs = sourceWsSlug` and then reads `destWs` back to start the collection load, so the effect depended on a value it had just written: the write invalidated the effect performing it, the flush aborted, and the aborted flush stranded unrelated reactivity across the pane. That is the CONVE-1688 hazard, and the comment directly above the effect asserted the opposite — that `open` was its only dependency. It could not bite on the first open. `destWs` already equals `sourceWsSlug` there, so the reset is a no-op write and nothing invalidates. It needs a real destination change, a close, and a reopen. Both branches now run inside `untrack`, so `open` really is the only dependency. Why the review missed it: all ten e2e cases opened the dialog exactly once. Thirteen plan-review rounds, per-task Codex loops and four full-diff rounds all reasoned about the effect from its comment, which claimed the property that was untrue. Adds the reopen case, which asserts the pane is still alive afterwards rather than only that the dialog returned — mutation-verified: it fails with the untrack removed. |
||
|
|
fbfbfcfe34 | test(web): e2e coverage for the copy/move dialog (TASK-2355) | ||
|
|
a6b48c75b9 |
fix(web): lane inline-create opens the split pane on desktop, stays put on mobile (IDEA-2298) (#1036)
The board lane `+` opens a Trello-style draft card (TASK-1676) whose Enter
handler hardcoded a full-page `goto(.../{item}?new=1)`. TASK-1676 predates the
split pane (PLAN-2105), so nothing revisited that destination, leaving the one
create gesture that already knows its title as the only card-open path that
bypasses the pane:
- Clicking an EXISTING card opens the split pane (`onItemOpen` → `?item=`);
creating one navigated the whole page away from the board.
- `?new=1` exists to drop you into the title editor of a fresh "Untitled" item
(`createNewItem`). On this path the title was just typed, so it re-opened the
title editor with that title select-alled.
- There was no viewport branching at all, so mobile — where the lane `+` is
fully present — got ejected off the board too, making a second add a Back
navigation.
`quickCreateInColumn`'s third param becomes `reveal` (caller INTENT) rather than
`navigate` (a destination): the local-index upsert is now unconditional so the
card always lands in its lane, and revealing means `openItemPane(item)` on
desktop and nothing on mobile. The page owns what revealing means, so BoardView
stays unaware the pane exists. The composer closes on submit on every viewport —
on desktop the pane takes focus, so keeping it open for rapid entry would fight
it. Revisit if feedback asks for mobile rapid-add.
The nav-guard's Save-all keeps passing `reveal: false`; saving drafts on the way
out must never open anything.
New e2e pins both destinations and, on both viewports, that the pathname never
changes and `?new=1` is never set. Two traps worth recording: the created card
renders off the SYNCHRONOUS local-index upsert, so it is not a sync point for
the navigation that follows — the first draft of both tests passed against the
reverted fix because the URL assertions raced an unresolved `goto`. Desktop now
waits on `?item=`; mobile can't poll an absence, so it proves the negative
positively by re-opening the lane composer (only possible if the board is still
mounted, and awaiting it gives a would-be navigation time to land). Verified by
mutating the fix back out: both fail for the right reasons, pass on the fix.
Gates: npm run check 0 errors, npm run test 490 passed, new spec 2/2, pane e2e
64/65 (the one failure is the pre-existing BUG-2334 SSE-toast flake, confirmed
by screenshot and passing in isolation). Codex review CLEAN (CONVE-735).
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
|
||
|
|
74813fcc72 |
revert(web): restore the pre-TASK-2328 item action bar, then make it fit (PLAN-2326 overturned) (#1035)
* Revert "feat(web): dissolve the item action bar into a new .tab-strip wrapper (TASK-2328) (#1033)" This reverts commit |
||
|
|
8bc3c5f4c9 | fix(e2e): graph tests open the drawer via the pane ⋯ overflow (missed in #1029 — only capstone/host were re-run locally) (#1030) | ||
|
|
26c3f02136 |
feat(web): pane action bar consolidates into the ⋯ overflow (TASK-2294 PR B) (#1029)
PLAN-2290 Phase 4, PR B. The pane's action bar becomes the mock's trio — star, quick actions, ⋯ — with the count-carrying jump badges (🌳 done/total, 📎 N) retained as tab shortcuts: - Dependency graph / Move to collection… / Share… / Delete… move into a pane ⋯ Menu (primitive; BottomSheet on mobile; Move is a drill-down view inside the same panel, LaneActionsMenu precedent — replaces the old standalone move dropdown/sheet + showMoveMenu state). - The redundant Timeline text button is removed (the Activity tab IS the timeline entry point). - The Delete… row opens the existing inline confirm strip in the bar; handleMove/reset paths repointed to the new menu state. - Capstone e2e updated: pre-peek opens the ⋯ and asserts the rows; while peeking asserts the trigger stays enabled (the BUG-2263 liveness guarantee) instead of opening — opening would activate the side. Gates: svelte-check 0 errors, 488 unit tests, capstone+host e2e 16/16, ⋯ menu runtime-verified (screenshot). |
||
|
|
d04b714ccb |
feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294) (#1027)
* feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294)
PLAN-2290 Phase 4, PR A. The mock's tabbed pane, built on the hard rule:
panels are CSS-hidden (.tab-hidden, display:none), NEVER {#if}-unmounted —
the collab editor, ChildItems/ItemTimeline SSE subscriptions, and
BacklinksPanel's count callback all carry mount side effects that must
survive tab switches.
- ItemDetail: pane-tabs tablist after the action bar; Details wraps Code
Context + .item-body (fields+editor, layout-{layout} preserved);
Relationships wraps relationships/add/children/backlinks (inside the
existing {#key itemSlug} block); ONE ItemTimeline instance serves both
Activity and Versions via the new visibleKinds render-filter. Tabs reset
to Details on item switch (guarded plain-let effect, no read-write loop).
Jump buttons switch-tab-then-scroll. Print shows all panels, no tab bar.
Tab clicks stay interactive while peeking and activate the side per the
focus-follows-editing model (deliberately NOT an exempt surface).
- ItemTimeline: visibleKinds?: ('comment'|'activity'|'version')[] —
filter-only over the one merged feed (no refetch on switch); composer
renders only when comments are visible.
- E2E: five specs updated — tab-click preludes where interactions target
tabbed sections; four frozen-master tests reworked to assert per-tab
visuals BEFORE the peek and DOM-based freeze proxies during it (the
per-surface freeze audit lives in masterFreeze/mutationGate unit suites).
Gates: svelte-check 0 errors, 488 unit tests, the five affected e2e specs
27/27 locally; runtime-verified collab badge synced across a full tab
round-trip, version filter (4 real cards), composer placement, editor DOM
alive throughout.
* fix(web): pane-tabs review fixes — title-Enter surfaces Details before editor focus; ARIA ids/roving-tabindex/arrow nav; block-drag hover integration restored via re-peek
Codex findings on #1027: (1) Enter-after-title-edit now sets
activeTab='details' + tick before focusing the editor (was focusing a
display:none node from other tabs); (2) tablist gains arrow-key roving
focus, per-instance aria-controls/id pairing ($props.id() — two ItemDetail
instances mount on the full-page host), tabindex discipline; (3) host
test 3 regains the end-to-end hover assertion: re-surface master Details
(activates), re-peek via the pane, hover the frozen editor, assert the
handle stays display:none — the reactive-editable choke verified in
integration again, not just by contenteditable proxy.
* fix(web): pane tabs use automatic activation on arrow nav (Codex — roving tabindex must follow focus; activation is free on display-toggled panels)
|
||
|
|
01a94d93a8 |
feat(web): Menu/MenuItem primitive — 3 menus migrated, escape-stack + portal + pointerdown dismissal (TASK-2292) (#1022)
* feat(web): Menu/MenuItem primitive — escape-stack ESC, portal mode, pointerdown outside-click (TASK-2292) PLAN-2290 Phase 2, PR 4 (final primitive). New shared machinery: - lib/components/common/Menu.svelte — anchored + portal modes (portal = fixed coords + flip/clamp, escapes card content-visibility containment), instance-scoped POINTERDOWN outside-click (structurally removes the BUG-2281 stopPropagation-on-rows detach workaround), ESC via the shared escapeStack at new priority menu=40 (one ESC closes menu before pane/drawer), roving keyboard nav, focus-in/focus-return, BottomSheet swap at 768px, --bg-raised panel skin. - lib/components/common/MenuItem.svelte — icon/hint/danger/menuitemradio rows. - lib/utils/clickOutside.ts + lib/utils/portalAction.ts — extracted from the hand-copied per-menu versions. - app.css: --bg-raised token (dark = tertiary; light = white). Migrated: ItemActionsMenu (portal mode, entire hand-rolled machinery deleted), QuickActionsMenu (anchored + sheetOnMobile, EmojiPicker exemption via exempt(), BUG-2281 workarounds removed), TopBar user menu (desktop + mobile branches deduped into one snippet; gains aria-haspopup/expanded + keyboard nav it never had). E2E locators updated to accessible-name form. Documented leave-alones: TopBar workspace-overflow menu (it IS a dndzone — conditional mount / focus-steal / pointerdown-close each break drag-reorder; in-file comments), LaneActionsMenu drill-down + WorkspaceSwitcher (Phase 3 / later). Gates: svelte-check 0 errors, 488 tests, make check green; runtime-verified via Playwright: user menu roving nav (ArrowDown x2 -> Admin), ESC closes via stack, kebab portal placement + edge-aware rows. * fix(web): Menu review fixes — form focus hand-off, drag suppression, scroll-close without refocus, resize close Codex findings on #1022: (1) QuickActions create-form now receives focus when it swaps in (the focused MenuItem unmounts on the flip); (2) clickOutside gains suppress() and TopBar's user menu passes isDragging||dragArmed so pill drags can't slam it shut (parity with the old drag guard); (3) portal scroll/resize dismissal calls onclose() directly — no trigger refocus fighting the user's scroll (parity with the old returnFocus=false); (4) resize now also closes portal menus (stale fixed coords). |
||
|
|
222c596a96 |
test(e2e): add BLOG-2289 v0.11 pane screenshot capture block (#1016)
Reusable blog-screenshot capture for the Pad v0.11 detail-pane post (pad-web/static/blog/pad-v0-11-item-pane/01-item-pane.png). Follows the existing BLOG-1007 / BLOG-1704 pattern; gated on PAD_BLOG_SCREENSHOTS=1 so it never runs in normal CI. Opens the docked pane via ?item=<ref> on a seeded, content-bearing task, and logs the browser session first so the pane's collab editor hydrates (WS auth is cookie-based) instead of capturing a loading skeleton. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
faf9b3734a |
feat(web): default new collections to Board — schema-aware (IDEA-2274, IDEA-2287) (#1015)
* feat(web): default new collections to Board view (IDEA-2274) Board becomes the baseline default view for new collections; existing collections keep their stored default_view (no migration). - Frontend fallback (settingsDefaults, collection-page defaultMode, shareView coerce, initial viewMode) -> board - Create/Edit collection modals default -> board - Backend template seeds (defaults.go, templates*.go) list -> board for ideas/plans/docs/hiring/interviewing collections (tasks was already board) - CLI `pad collection create` and MCP mapCollectionCreate defaults -> board - Curated create-modal presets with deliberate list curation (Meeting Notes, Decisions, OKRs) intentionally left as list - Pin the three list-keyboard-nav pane E2E tests to ?view=list Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): board default reaches public share page + ItemDetail fallback (Codex round 1) Codex review found the public share route (s/[token]) derives its owner default view via a separate `?? 'list'` fallback that bypassed the coerceSettings change, so settings-less/legacy collections rendered List on public share pages. Align it (and the pre-init selectedBase) to board. Also align ItemDetail's inline CollectionSettings fallback (default_view is unused there, but keep it consistent with settingsDefaults). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(collections): group Contacts board by relationship, not status (Codex round 2) Contacts has no `status` field, so defaulting it to Board grouped by the default `status` rendered every card in a single Uncategorized lane. Set BoardGroupBy=relationship so the board shows real lanes. All other board-defaulted seed collections have a status field or an explicit board_group_by (verified: Companies/Conventions/Playbooks/Docs have status). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): always serialize ?view= so a List URL survives a board default (Codex round 3) buildCollectionUrlParams treated List as the implicit URL view and omitted it. With Board now a possible collection default (IDEA-2274), a List selection on a board-default collection produced a URL that, when copied or opened without the sender's localStorage, resolved back to Board. Always serialize the view mode; add a covering unit test. Verified the pane E2E suite (URL-equality assertions) stays green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
8c710e1db9 |
fix(web): stop paneOverlay ref-count effect self-looping on mobile (BUG-2284) (#1009)
PR #1007 (TASK-2131) added a PaneHost `$effect` that calls `paneOverlay.enter()`/`leave()` to inert the app-shell chrome behind the mobile detail-pane overlay. `enter()`'s `overlayCount += 1` READS `overlayCount` inside that tracked effect scope, so the effect took a reactive dependency on the very signal it writes: enter() dirtied the effect → it re-ran → enter()d again → `effect_update_depth_exceeded`. Svelte aborts the flush, stranding the rest of the subtree's reactivity — `paneMintForRoute` stopped recomputing, so the mobile pane (and its Back chevron) rendered EMPTY. The E2E `pane-controller` mobile-overlay tests caught it; #1007's own manual check verified the ARIA attributes but not that item content still rendered. Fix: `untrack` the count read in enter()/leave() so a write from an effect never establishes a self-dependency (the write still notifies the layout reader). The ref-count mutators are written from effects by design, so the untrack belongs in the store. Also fixes the second collision from the same #1007 change: the pane is now `role="dialog"` on mobile, so pane-controller.spec.ts:771's `[role="dialog"]` + text locator matched BOTH the pane and the BottomSheet (strict-mode violation). Target the sheet by accessible name ("Quick actions") instead — the pane's is "Item detail". The effect_update_depth_exceeded runaway only manifests under the real browser scheduler (not jsdom/vitest), so the E2E overlay tests own the loop regression; the unit tests lock the ref-count semantics. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
06d7e429e5 |
fix(web): keep the inline "New quick action" form open on click (BUG-2281) (#1005)
Clicking the QuickActionsMenu footer "+ New quick action" flipped
showCreateForm=true, unmounting the footer's {:else} branch (the very
button clicked). Svelte 5 flushSyncs after a delegated event handler, so
by the time that click bubbled on to the <svelte:window> click-outside
handler the button was detached — target.closest('.quick-actions-menu')
returned null, and handleWindowClick treated it as an outside click and
closed the whole menu, wiping the create form the instant it opened. The
create-form Cancel button had the same detach-then-close quirk (closed
the menu instead of returning to the action list).
handleTriggerClick already guards this with e.stopPropagation();
handleOpenCreateForm and the Cancel handler did not. Add the same guard
to both. Adds a Playwright regression test (mutation-tested: fails on the
pre-fix code, passes after) — the inline form is exercised in a real
Chromium event pipeline for the first time (jsdom doesn't reproduce the
mid-bubble detach, and the capstone spec only asserted the button was
visible, never clicked it).
Also documents BUG-2280 in ItemDetail.svelte: the QuickActionsMenu
oncollectionupdated callback's `{@const keyedSlug = itemSlug}` fence was a
Svelte-5 no-op, but the callback is already switch-safe by two independent
layers (the child-side collection-id guard reads the LIVE parent
collection and drops a cross-collection callback; loadData's identity
clause forces the correct collection regardless). Replaces the dead no-op
fence with a comment explaining why it's safe and warning against
re-adding a no-op snapshot fence (the literal BUG-2129 trap). No
behavior change in ItemDetail.
BUG-2281: real, fixed. BUG-2280: investigated, not a live bug (wontfix).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
34233a2a25 |
fix(e2e): de-flake pane j/k re-target test by opening the first row (BUG-2279) (#1003)
pane-controller.spec.ts:160 flaked ~50% (fails once, passes on retry). Root cause via instrumentation: the test opened a NAMED seeded row and pressed `j` (down) expecting the pane to re-target to a different item. But the two seeds share a same-second created_at, so their list order is a non-deterministic tie-break (BUG-2270) — the named row could land LAST, where `j` clamps at the final index (Math.min(idx+1, len-1)) and the cursor doesn't move. The pane-follow then correctly finds the focused row is already the paned item and skips (no re-target), so `openItemParam` stays put and the assertion fails. Not a product bug — the follow logic behaves correctly. Fix is test-only: open the FIRST rendered row instead of a named one, so `j` always has a row beneath it to move to, regardless of seed tie-break order. Verified: :160 now 10/10 stable in isolation (was ~50-60% flaky); full pane-controller.spec.ts 21 passed. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
9d18f12893 |
fix(collab): flush live editor into items.content before version restore (BUG-2271)
Before a version restore, the initiating client flushes its live collab editor markdown into items.content (the collab server is a dumb relay and can't render the Y.Doc), so the server-side 'Restored from…' undo-point captures in-flight edits instead of losing them when the restore prunes the op-log. Best-effort: a genuinely-failed pre-restore flush warns the user (non-silent) and the restore still proceeds. Narrow reconnect/cursor-0 window documented as an accepted residual. Confirming Codex (high effort): 2 rounds — silent-flush-fail + spurious-warning + E2E false-pass all closed; deterministic request-ordering E2E green. Web CI red only on the pre-existing npm advisory (BUG-2278). https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
1f12b26f33 |
fix(web): item field edits use fields_patch + optimistic concurrency (BUG-2273) (#991)
Adopt the IDEA-1480 / MCP v0.14 item-level merge + optimistic-concurrency contract in the web editor's per-field save. `updateField` now sends a single-key `fields_patch` + `expected_updated_at` instead of a full `fields` blob, with a bounded refetch-and-retry on 409 `update_conflict`. Fixes concurrent-field-edit clobber and the schema-migration-race value restore. Includes the BUG-2129 E2E test update to the new wire shape. Confirming Codex pass: CLEAN. E2E green on rerun. (Web/Go CI red only on pre-existing dependency advisories tracked in BUG-2278.) https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
40f88052cd |
fix(collab): version restore via prune+reseed (BUG-2264) (#990)
Version restore didn't reconcile the live Y.Doc: peers kept editing a Y.Doc built on pre-restore ops, and their next collab-snapshot flush clobbered the restored items.content. Reworked restore to prune+reseed — the restored content becomes canonical and every peer converges on it (unflushed edits are discarded, which is exactly restore semantics), replacing the earlier applier/epoch/watermark routing. handleRestoreItemVersion drives RoomManager.ForceRefreshRoom under the per-item lock. Hardened across Codex xhigh review rounds: - Atomicity: pre-prune MAX(op-log), the items.content write, the "Restored from…" version, the op-log wipe, AND both durable restore boundaries all run in ONE store transaction. A failed commit rolls back all of it — no divergent state, no fail-open boundary. - Unambiguous commit signal: UpdateItem reads the updated row WITHIN the tx (getItemTx) before commit, so a read failure can't make a committed update look failed and the returned seq is this restore's. - Restore freeze: conns are paused via a dedicated rc.frozen flag (NOT canWrite) so the auth-revalidation loop can't thaw the freeze mid-restore or promote a viewer; pickApplier + the applier-ack handler reject frozen conns so a concurrent external PATCH can't falsely succeed. - Stale-flush boundary: pre-prune MAX+1 fences in-flight snapshot cursors under the same item lock. - force_refresh fan-out deadlock: per-conn timer-close so a wedged writeLoop can't hang the fan-out + item lock. - Stale-SEED clobber: the client announces the item.seq it seeded from (?content_seq=) on every (re)connect; Join force_refreshes any seed that predates the last restore. Residual #1 (restart-durability) CLOSED durably, for BOTH stale vectors — the in-memory fences didn't survive a restart, so a surviving cursor-0 pre-restore browser tab wasn't fenced on reconnect. Two nullable per-item columns (migration 075 SQLite / pg 053), both stamped in the restore's own tx (atomic with the content write + op-log prune): * items.last_restore_seq — the content generation. Join's stale-seed fence reads it (via store.ItemLastRestoreSeq) when the in-memory fast-path misses (after a restart); if that read errors, Join fails CLOSED via a RETRYABLE plain close (not a force_refresh, which would discard the Y.Doc and spin an unbounded refresh loop) so the client reconnects with backoff, Y.Doc intact. * items.restore_boundary_op_id — the op-log-id boundary. The collab-snapshot flush gate reads it (via store.ItemRestoreBoundaryOpID) when the in-memory RestoreBoundary misses (after a restart), failing closed (409) on a read error, so a surviving tab's stale HTTP flush is fenced too. No SCHEMA_VERSION bump — durable columns are not a Y.Doc node-spec change. Deferred to BUG-2276: (a) a Postgres commit whose ack is lost is treated as rolled-back (needs commit-outcome reconciliation; SQLite unaffected); (b) a restore rollback racing an in-flight external-applier ack can drop the ack and retry/fall back (needs the applier flow serialised under itemLock at a 30s-stall cost). NOTE(BUG-2270): ForceVersion can mint same-second version rows; the item_versions ordering tie-breaker is tracked separately. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
d14bceb3e2 |
fix(web): render the Rich/Markdown mode toggle on the peeking side too (BUG-2263 follow-up) (#988)
The invisible-freeze work (PR #987) left ONE surface still gated on `!peeking`: the editor's Rich⇄Markdown mode toggle. It was hidden on the passive preview because it's a provider-LIFECYCLE control — switching to Markdown nulls collabKey and DESTROYS the retained collab provider, which retain-alive (D2) forbids WHILE peeking. But under focus-follows-editing a click on the toggle fires the host's pointerdown-capture activator FIRST, flipping activePane to that side (peeking=false) before the click's onclick runs. So by the time the flip executes, the side is already ACTIVE and tearing down its own provider is normal active-side behavior — the "teardown while peeking" the gate feared can't happen via a click. The onclick's existing `if (peeking) return` guards (plus the `|| peeking` mid-flush rechecks) remain as the backstop for a re-peek DURING the async flush (e.g. the user clicks the other side mid-flip). So drop the `{#if !peeking}` render gate — the toggle now renders on both sides like every other invisible-freeze surface. Verified in the browser: opening the pane shows the toggle on the peeking preview; clicking the peeking master's "Markdown" button activates it and flips to raw mode in ONE gesture, ProseMirror unmounts cleanly, exactly one typeable editor throughout, zero console errors. Tests: FreezeProbe renders mode-toggle unconditionally; masterFreeze asserts it present while peeking; new host e2e opens the pane, confirms the toggle on the peeking side, and asserts the one-gesture flip (a successful flip proves activation preceded the guarded onclick). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
29e49e4c63 |
fix(web): make the master/pane freeze invisible to the user (BUG-2263) (#987)
On the full-page item host, opening a detail pane froze the non-active
side by DEGRADING its DOM — fields became plaintext, buttons vanished,
the title turned read-only. Under the focus-follows-editing model
(PLAN-2179) the freeze is transient and one-click-reversible, so that
degradation was pure user-visible friction: you'd click a plaintext
field, the click would flip activePane, the field would re-render into a
live control, and you'd have to click again.
The freeze exists ONLY to keep exactly one TYPEABLE collab content editor
(single-owner of the editorStore/activeItem/tab-title singletons). It is
NOT a data-collision barrier: master and pane are always DIFFERENT items,
whose collab state is fully itemID-keyed / instance-local, and most REST
surfaces (fields, title, assign/role, tags, move, delete, share,
relationships, children, comments, reactions, archived restore, star) are
single-item, server-gated, side-independent writes.
So drop the `!peeking` term from those REST surfaces — gate them on
`canEdit` alone (their pre-freeze contract) — and keep `peeking` ONLY on
the content editor and its chrome (rich + raw editors, bubble/link
popover, provider-lifecycle mode toggle + retry). The content editor is
already invisible: the host's pointerdown-capture flips activePane before
the click's caret placement (TASK-2180 no-remount reactive editable), so
one gesture activates the side and lands the edit. Now the whole side is:
click anywhere -> edit it, no visible mode.
Two surfaces are NOT side-independent and stay confined to the active side
(the two documented exceptions, found by Codex review):
- Version restore REST-writes this item's `items.content` directly, which
collides with the retained Y.Doc on a peeking side. Kept frozen via a
new ItemTimeline `restoreFrozen={peeking}` prop; comments/reactions
(separate REST entities) stay live.
- The quick-actions "Manage/New" controls rewrite the whole collection
`settings` from a per-item snapshot (last-write-wins across two items in
one collection), so they gate on `isOwner && !peeking` and recheck
canEdit at dispatch; the read-only prompt-copy actions stay visible on
both sides.
Scope: full-page host only. The collection route never passes peeking, so
`mutationsEnabled === canEdit`, `frozen={peeking}` is inert, and
`restoreFrozen` defaults false there — every change is byte-identical on
that route. `mutationsEnabled` survives but now scopes to content-editor
chrome only.
Tests: rewrote the masterFreeze unit probe + both full-page e2e specs
(host + capstone) to assert the new contract — the frozen side keeps its
editable title/fields/buttons; only its content editor flips
contenteditable=false. The freeze signal moved from `h1.title-readonly`
to the ProseMirror `contenteditable` attribute. Added a runtime-mutation
e2e assertion (a field edit on the frozen master PATCHes the correct item),
a real-QuickActionsMenu integration test, and unit coverage for the two
exceptions.
Two PRE-EXISTING concurrency issues were surfaced by the review (version-
restore gating + collection-settings write-exposure are byte-identical to
main, so this PR neither introduces nor worsens them); filed as BUG-2264
(restore <-> Y.Doc reconciliation) and BUG-2265 (collection-settings
optimistic concurrency).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|