Skip to content

PLAN-2392 phase 3a: viewer unification (parity commit + DR-4b a11y contract) - #1060

Merged
xarmian merged 12 commits into
mainfrom
feat/attachment-viewer-unification
Aug 6, 2026
Merged

PLAN-2392 phase 3a: viewer unification (parity commit + DR-4b a11y contract)#1060
xarmian merged 12 commits into
mainfrom
feat/attachment-viewer-unification

Conversation

@xarmian

@xarmian xarmian commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Phase 3a of PLAN-2392. Retires the editor's hand-rolled <dialog> lightbox and unifies every attachment-image surface onto one body-portaled viewer with a real modal contract; makes the raster MIME allowlist gate total on every open path; and makes the app shell's global key and gesture owners defer to a frontmost viewer.

Not in this phase: zoom (3b), the action toolbar and DR-20 convergence (3c), touch (3d). Images open the unified viewer; files open the phase-2 panel, exactly as merged.

Why this phase is the risky one

It deletes a working native modal. showModal() was providing five guarantees for free — top-layer stacking, an inert background, a focus trap, focus restore, and Escape — and this PR is where each stops being free. Nothing downstream catches a gap in them, and jsdom's <dialog> polyfill only toggles attributes, so none are visible to a unit test.

Guarantee Replacement Proven by
Top-layer stacking body portal + z-index: 100000not equivalent hit-test vs a synthetic rival; paint order for the platform top layer is unproven
Inert background viewerBackdrop refcounted lease stack focusable probes in every body child, focus refusal as the oracle, real top bar inert by cascade
Focus trap Lightbox Tab handler + paneFocusables Shift+Tab wrap asserted by name; two-viewer case
Focus restore restoreFocus() on teardown invoker unfocusable while open, focused after close — so restore-before-release cannot pass
Escape escapeStack, sole owner both real route guards, asserting which layer closed
show() vs showModal() feature-detected dialog:modal fully closed — the one row jsdom cannot touch at all

Decision record (highlights)

  • DR-4a/4b — one viewer, and retiring <dialog> means re-implementing what it gave for free. role="dialog" + aria-modal, portal to <body> (not portalAction, which targets the nearest ancestor dialog), focus entry to the first tabbable descendant, Tab trap reusing paneFocus.ts, real inert on every body child, escapeStack as sole Escape owner.
  • DR-16 — the viewer opens for an exact raster allowlist, enforced by one helper every path calls. Lightbox fails closed: an entry whose MIME is not positively allowlisted is not viewable. The filter is $derived, so a record resolving to unsafe after open is re-filtered.
  • DR-8{itemId, hostToken} addressing, so a master and a peeked pane cannot consume each other's events. Permission never travels on the event.
  • Lifecycle is keyed on the item resource (item.id + a dedicated generation), not the route: a collection-only URL change preserves the item and must not close the viewer; a same-resource refresh must not either.

Parity exceptions (four, all named)

  1. Navigation — the timeline's ←/→ list is filtered by the allowlist and indexed by attachment id. The gate cannot be total while a sibling list is built without MIME.
  2. Lifecycle — the viewer clears on a resource switch (PLAN-2105 switch-safety class).
  3. BUG-2426 — the Tiptap capabilities push was a no-op; unrelated to viewer parity, carried here because it lives in the file this phase rewrites.
  4. BUG-2441 — Escape consumption is now event-scoped, which deliberately changes DockedSheet/BottomSheet/TopBar Escape handling. Every touched owner has an empty-stack regression proving behaviour with no viewer present is byte-identical.

TASK-2430's global sweep is modal-contract work, not parity work: it intentionally changes route, graph, pane, sidebar and sheet behaviour while a viewer is frontmost.

Defects found and fixed inside this PR

  • The timeline's ←/→ list was built from img[data-attachment-id] with no MIME, so opening a safe PNG and pressing Right could reach an SVG.
  • The viewer failed open on unresolved MIME — and a test asserted that behaviour.
  • z-index: 1000 let the body-portaled emoji picker (99999) paint over the viewer — DR-4c's layering hazard returning via z-index.
  • activate() survived a transient load failure, so a stale event could still open a hidden image.
  • The timeline's a11y pass stripped role/tabindex from draft-comment images (attMeta covers saved bodies only), and its delegated keydown had no modifier check, so Cmd/Ctrl+Enter — the comment editor's submit — also opened a viewer.
  • BUG-2441, twice: once as a stale lease mid-dispatch, then again via a held Escape, whose auto-repeats are fresh unmarked events.

Test plan — gates that actually ran

  • make check green on the integrated tip: golangci-lint, go test ./..., govulncheck, npm audit, svelte-check.
  • 1092 web unit tests (746 on main).
  • 32/32 browser tests, run from the main checkout against a correctly-built binary, with no expected-failures.
  • Mutation-verified, not argued: removing the event-scoped Escape check fails both BUG-2441 tests; disabling nextTrapTarget's Shift wrap fails the trap test with Received: null; removing the repeat guard fails both held-Escape tests. Sources restored and rebuilt each time.
  • make test-pg not required — no Go, no store query.

Known pre-existing e2e flakes, not from this branch: BUG-2334 and BUG-2447, both passing in isolation and reproduced on baselines without these specs.

Filed, not fixed

  • BUG-2447pane-collab-teardown flakes under parallel load (pre-existing).
  • IDEA-2437 — an address epoch so an A→B→A round-trip is distinguishable. Documented, tested as accepted behaviour, and not fixable from inside the NodeView (the address reader is a getter with no epoch).

Review note

Nine of twelve tasks first shipped green tests that could not fail on their own hardest property — one asserted the vulnerability it existed to close; one I reviewed and endorsed was false-green. All were caught by a per-task adversarial review round asking "would this fail if the implementation were wrong?", and none by the gates. That question is the reason the defect list above exists.

Draft: do not merge without Dave.

https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC

xarmian added 9 commits August 6, 2026 16:50
New `web/src/lib/a11y/viewerBackdrop.ts` (PLAN-2392 phase 3a): a
refcounted, lease-stacked owner of `inert` on `document.body.children`
for body-portaled viewer surfaces, plus the modal-arbitration helpers.

- Only the FRONTMOST lease's exempt root stays interactive; release
  RECOMPUTES the desired inert set from the stack rather than undoing
  its own writes, so out-of-order release is correct.
- Records only what it set, so pre-existing `inert` survives.
- ONE shared childList observer, disconnected at zero leases, so a body
  portal arriving mid-lease is inerted too.
- Releasing the frontmost lease hands focus to the next viewer's first
  tabbable descendant; releasing a background lease changes nothing.
- `isBlockedByModal` derives from lease state (never DOM `inert`),
  returns false on an empty stack, and uses a feature-detected
  `dialog:modal` for the native branch.

Nothing consumes it yet — independently green.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
`Lightbox` was a `role="presentation"` fixed div with a local `<svelte:window>`
keydown handler and no focus management at all. 3a's later tasks delete the
editor's hand-rolled `showModal()` dialog and route inline body images here, so
everything that dialog was getting from the platform for free has to exist here
first (DR-4b).

The contract:

  - `role="dialog"` + `aria-modal="true"` + an accessible name (the image alt,
    else "Attachment viewer"), tracking the image CURRENTLY shown. The controls
    get real `aria-label`s too — their text is "✕" / "‹" / "›", and `title` does
    not win over element content for the accessible name.
  - Portaled to `<body>` DIRECTLY — deliberately not `portalAction.ts`, which
    targets the nearest ancestor `<dialog>` when one exists: the opposite of
    what a top-most surface needs. `<body>` is also the parent the backdrop
    manager's inert bookkeeping requires, and the only one with no ancestor that
    could establish a containing block and silently trap a `position: fixed`
    overlay.
  - `wsSlug` CAPTURED at open, not read live: the pane switches workspace
    without remounting what is above it, so a live read could rebuild
    already-captured attachment ids against a different workspace.
  - Focus entry to the first TABBABLE DESCENDANT via `paneFocusables` (the root
    is `tabindex="-1"` only as the no-controls-yet fallback); restore on close to
    an OPTIONAL `invoker` prop, verified still connected AND focusable — else
    focus is parked on `<body>` deliberately. With no invoker threaded it falls
    back to whatever held focus at open, so the producers that thread one only in
    TASK-2431 don't come out of this commit worse than they went in. The restore
    declines when something else already owns focus.
  - Background inertness via TASK-2427's manager (`acquire(exemptRoot)`), never
    a hand-rolled `inert`. Released BEFORE the focus restore, and the returned
    `stackEmpty` decides: with a viewer still open the manager has already handed
    focus into it and this one stands down.
  - Tab trap through `paneFocusables` / `nextTrapTarget` — the pane's tested trap
    math, not a second implementation.
  - ONLY the frontmost viewer (`isViewerFrontmost`) traps Tab, handles ←/→ and
    consumes Escape. Handlers are global and `nextTrapTarget` deliberately
    redirects out-of-container focus INWARD, so a background viewer would
    otherwise drag focus out of the viewer in front of it. And the viewer stands
    down entirely (`isBlockedByModal`) while a `showModal()` dialog is open over
    it: the top layer is above any body-portaled surface, so the frontmost LEASE
    is not always the frontmost SURFACE, and the manager keeps such a dialog
    operable on purpose.
  - Escape: the local branch is DELETED, not gated — it ignored
    `defaultPrevented`, so alongside the stack it gave Escape two owners and let
    one press collapse two layers. `escapeStack` is now the sole owner, at a new
    `viewer` priority (50) above `menu` (40).

`AttachmentViewerHost` stops restoring focus itself and threads `invoker` down
instead. Its own restore ran while the viewer still held the backdrop lease —
i.e. while the invoker sat inside an `inert` body child, where it is not
focusable at all — so it would have silently become a no-op the moment this
commit landed. The only correct moment is after the lease is released, which is
inside the viewer's teardown.

Atomic with the route guards, because the stack is unreachable without them:
both `[collection]` and `[collection]/[slug]` bailed out of the ESC chain on
`document.querySelector('dialog[open], [role="dialog"]:not(.item-pane)')`, which
the viewer now matches — Escape would have been dead. Both call the shared
`hasForeignEscapeOwner()` instead. It KEEPS the ARIA branch (`BottomSheet` /
`DockedSheet` are shipped `role="dialog"` Escape owners with no stack
registration; dropping it would regress them) and narrows only the NATIVE branch
to a feature-detected `dialog:modal`, falling back to today's `dialog[open]`
where the pseudo-class is unsupported.

The backdrop's `z-index` goes to 100000. At 1000 it was under the desktop emoji
picker's body-portaled dropdown (99999) — and the app shell wrapper is
`display: contents`, so every fixed overlay in the tree competes in the ROOT
stacking context; being a body child is not protection. A surface that paints
over a viewer that has inerted it is visible-but-untouchable, the worst of both.
The full sweep, and the rule that a new overlay above this value is a bug, are
recorded at the declaration. The one thing legitimately above it is a native
`showModal()` dialog, which gets there via the top layer and no z-index at all.

Collision audit for `[role="dialog"]`-as-foreign-modal consumers:

  - `app.css` `@media print` — the viewer matches, and should: a transient
    overlay must not print. Documented in place (a JS-only grep misses this).
  - `paneFocus.ts` `PANE_EXEMPT_SURFACE_SELECTOR` and `PaneHost`'s mobile trap +
    focus-follows classifier — the viewer matches, and must: it runs its own
    trap and key handling.
  - `web/e2e/**` incl. `e2e/lib/*.ts` — no shared dialog selector constants; the
    only bare `getByRole('dialog')` is
    `workspace-bundle-roundtrip.spec.ts:204`, claimed by TASK-2430 by name.

Tests: 36 cases for the modal contract, 10 for `hasForeignEscapeOwner`, plus the
fallout of portaling (host tests can no longer scope by container — ownership is
proven by distinguishable payloads and destroying a known host; the strip's
Escape case now drives the stack). Comments state what jsdom cannot prove — real
inertness, layout/stacking, real Tab traversal — which is TASK-2436's browser
suite, and mark the one assertion whose guard is genuinely indistinguishable from
its neighbour in jsdom. Verified by mutation: 26 mutations run, all caught except
that documented one.

`escapeGuardWiring.svelte.test.ts` is a deliberately narrow addition: the
Lightbox tests drive a route-SHAPED driver they define themselves, so they prove
the shape and not that either route still calls it. This asserts against the real
source of both route files that `hasForeignEscapeOwner()` is imported, called as
an early return, and called BEFORE `runTopEscape()` — catching deletion,
re-inlining of the old selector, and reordering, which is the actual regression
risk. Mounting a route under vitest to prove it behaviourally is not worth its
cost; the behavioural proof is TASK-2436's browser suite. Two things stop it
being a grep that lies: comments are stripped first (every one of these strings
now appears in prose in those files, so a whole-file search could be satisfied by
a comment), and the assertions are scoped to the handler that actually calls
`runTopEscape` rather than to the file. Verified against a commented-out guard, a
guard moved to an unrelated helper, and a reverted selector under different
quoting — while a prettier-style reflow of the guard still passes.

TASK-2429
…ce (TASK-2431)

DR-16's allowlist gated the image the user CLICKED. That is not a gate.

The timeline built its ←/→ sibling list from every `img[data-attachment-id]`
in the comment body with no MIME consulted at all, while the markdown renderer
emits an `<img>` for any `image/*` — correct for RENDERING, wrong for OPENING.
So a user could open a safe PNG and press Right onto an `image/svg+xml`. The
whole list is now resolved from the CACHED probe metadata and filtered through
`canOpenInViewer`, on both the mouse and the keyboard path; the index is
derived from the clicked attachment's ID rather than its DOM position, because
filtering reindexes everything after the first refusal. An unresolved MIME
fails safe and is retried on a later probe run — no cold-start regression,
since an unprobed thumbnail renders as a placeholder, not an image.

A refused thumbnail is no longer a dead control either: `role="button"`,
`tabindex` and the "View image" name now track the same predicate, so a
filtered-out SVG is not a focus stop whose activation does nothing. That pass
tracks the RENDERED set, not the fetched one — the pane's Activity / Versions
tabs rebuild every comment card without changing `entries`, and the rebuilt
images were left mouse-openable with no keyboard route at all.

The timeline also had NO A→B viewer reset — `lightbox` was cleared on close and
nowhere else — so a workspace switch under the same ref left a viewer up,
rebuilding URLs for the previous workspace's ids. It now clears on a view
change. Both direct mounts are keyed per open, like the bus host's, so the
viewer's untracked capture of its index can never be reused.

`Lightbox` re-states the rule at the point of USE, and FAILS CLOSED: only a
positively allowlisted `mime_type` is viewable, so a null / unresolved one is
not. It is the last thing between a set and a rendered image — the place where
the benefit of the doubt is worth least — and admitting null let an emitter
hand over `[safe, unresolved]` and the user arrow onto the unresolved one. The
producers lose nothing: the strip always has the MIME from its list row, and
the timeline already excludes unresolved entries. The contract for new
producers is therefore to RESOLVE BEFORE EMITTING. The filter is `$derived`
rather than captured, so a record whose MIME resolves to something unsafe
after open, a set replaced under an open viewer, or an entry removed beneath
the position the user navigated to are all re-answered rather than trusted;
the shown index clamps instead of blanking.

`LightboxImage` gains `mime_type`, `filename` and — nullable — `size_bytes`,
`width`, `height`. The dimensions have no reader yet: they land now so phase
3b's pixel-based loading policy need not reopen the event, the host and every
producer. The component's own `{id, alt}` twin is gone; the channel's
declaration is the only one. The strip threads the full row (it had been
dropping `width`/`height` at `StripAttachment` and `size_bytes` at the mapping)
and both producers now pass the invoking element, so focus returns to the tile
rather than relying on the viewer's held-focus fallback.

NOT changed: `isImageMime`. It decides `<img>` vs chip and governs deferred
share-page surfaces; this phase gates the viewer OPEN, not the render.

Tests: a mixed safe/unsafe/unresolved list driven through mouse, keyboard and a
full ←/→ cycle against the REAL viewer (what an arrow key lands on is the
claim); the set changing UNDER an open viewer — resolved-unsafe-after-open,
removed, replaced, appended; and the payload each producer emits, fed unsafe
and unresolved rows rather than only safe ones, since a stub-based payload test
on safe inputs cannot fail when the gate does. One earlier test asserted that
an unresolved MIME OPENS — it pinned the hole open, and is now the test that
it must not. Every guard was mutation-checked; each kills the tests that
cover it.
… (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
The AttachmentImage NodeView's <img> opened the viewer on click and was
reachable by no other means: no role, no tabindex, no accessible name. Give
it the DR-12 button contract, and route BOTH activation paths through one
shared activate().

The MIME gate used to live inside the click handler, which made it a
property of the MOUSE rather than of activation — a keyboard path that
opened on its own would have bypassed it. One canActivate() predicate now
owns the gate and is read by activate() AND by the semantics pass, because
'can be opened' and 'announces itself as openable' are the same question
and a dead focus stop is what they look like when they disagree.

Keyboard handling: stops propagation before activating (ItemTimeline
delegates thumbnail handlers across its whole entry list, and that list
contains live CommentEditor instances rendering this very NodeView, so
without it one keypress opened two viewers); ignores MODIFIED keys, since
Cmd/Ctrl+Enter is CommentEditor's submit binding; and suppresses key
REPEATS without re-activating, so a held key opens one viewer rather than
one per repeat.

Semantics are conditional on the image actually being a control — no uuid,
a confirmed deletion, a load-failure placeholder, or a probed
non-allowlisted MIME each take role/tabindex/aria-label back off (and blur
it first) rather than leave a focus stop that announces itself as a button
and does nothing. The MIME clause is a judgment call: the contract names
only deleted/missing, but it is the same dead-stop rule and the same thing
ItemTimeline does, and its cost is documented in place.

The accessible name is alt with a GENERIC fallback: there is no filename on
the node's attrs and the HEAD metadata carries none, so the filename form
DR-12 sketches has no source here.

DECLARED CROSS-FILE FOLD-IN — ItemTimeline.svelte no longer manages images
a live editor owns (new isEditorOwnedImage predicate). Making the NodeView
image focusable made two of its behaviours reachable that were not before:
its accessibility pass stripped role/tabindex from any image whose UUID is
not in attMeta — which is every image in a DRAFT comment, since attMeta is
probed from SAVED bodies only — and its delegated keydown, having no
modifier check, opened a viewer on the Cmd+Enter this node now deliberately
lets through. Both are regressions this commit introduces, so both are
fixed here. Its own rendered thumbnails are untouched.

That wiring is covered by an integration spec that mounts the REAL
ItemTimeline with a REAL unstubbed CommentEditor, rather than a copy of the
delegation logic: every assertion about a guard fails when that guard is
deleted from the component.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
… (TASK-2433)

The inline `pad-attachment:` image NodeView opened its own hand-rolled
`<dialog>`: `openImageLightbox` appended one to `document.body` and
`showModal()`d it. It worked, and everything the modal contract is made of
came free from the platform — top layer, inertness, focus trap, focus
restore, Escape. This commit deletes it and emits on the viewer channel
instead, so the `Lightbox` that `AttachmentViewerHost` mounts is the only
viewer on this route, with the lease-stacked backdrop, the escape ordering
and the DR-16 filter re-applied over the whole set. Shaped as a pure swap
so the deletion is reviewable on its own.

THE MIME IS RESOLVED BEFORE ANYTHING IS EMITTED, revising the
decomposition's "keep today's positively-known gate". TASK-2431 made
`Lightbox` fail closed on an unresolved MIME, so an event carrying
`mime_type: null` is not "let the viewer decide" — it is a viewer that
mounts and renders no image. Activation now awaits `fetchAttachmentMetadata`
(a cache hit in the common case) and emits only on a positively-known
allowlisted answer. That also closes a mid-phase bypass: the old gate read
`knownMime` only when truthy, so a click landing before the lazy probe
resolved opened the original file, and a later unsafe answer did not close
it. What it costs, deliberately and temporarily, is that a `transient`
probe now opens nothing — the four-branch matrix that makes the gate total
is TASK-2434's.

AND THE CHANNEL ENFORCES IT, because a rule only one producer follows is a
convention, not an invariant — and the failure mode is silent precisely
because the viewer fails closed. `notifyViewerOpen` now takes a set whose
`mime_type` is non-nullable (`ViewerReadyImage` / `ViewerOpenRequest`), so a
forgetful producer is a compile error rather than an image that does not
open, and it refuses a set at runtime unless every entry is positively
allowlisted, the way it already no-ops on a missing address. The WHOLE
emission is dropped rather than the offending entry: `index` and
`attachmentId` name a position in the set the producer built, and silently
renumbering it would open the viewer on a different image than the one
activated. `LightboxImage` is untouched — the consumer side stays nullable,
because its records are live and `size_bytes` / `width` / `height` must
remain optional for 3b. `events.test.ts` asserted the permissive behaviour;
it now asserts the refusal, with a control so a gate that refused
everything cannot pass.

The await is new, so the fences are too: the gate's premises are re-checked
on the far side (teardown, a uuid swap, a deletion, and a host that moved —
the comment composer's address is live across an item switch), and one
activation at a time. That latch is generation-stamped: it is released by
the resolution's own finalizer AND by a uuid swap, which this NodeView
outlives, so an unconditional release would let a superseded request unlock
the one that replaced it, and a HEAD that never settles would otherwise
leave the new image permanently unopenable.

The `.attachment-image-lightbox` CSS goes with it; it lived in `app.css`,
not the TS file, where a JS-only sweep would have left it.

Tests assert the emitted PAYLOAD, not the dialog's absence — an
implementation that deleted the dialog and emitted nothing satisfies "no
dialog" — and a new spec drives the WHOLE route with nothing stubbed but
the network: real NodeView, real bus, real `AttachmentViewerHost`, real
`Lightbox`, asserting a viewer in the document, addressed to the right
host, showing the un-variant original. Verified by mutation: emit-nothing,
emit-unresolved-MIME, drop-the-allowlist-check at either the producer or
the channel, gate the set on its first entry only, drop the one-at-a-time
latch or release it unconditionally, drop any post-await fence, null the
invoker, and append an overlay to `documentElement` each fail at least one
test. Two of those needed cases the existing specs could not reach: every
prior DR-16 test selected the node first, which pre-resolves the MIME
through `canActivate()` and leaves the activation path's own check
unexercised — precisely the state a body image is in when it is clicked.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
…434)

TASK-2433 made the inline-image activation path resolve the attachment's
MIME before emitting and refuse anything not positively allowlisted. That
refusal was binary: `ok` + raster opened the viewer and every other result
returned silently, leaving two states where a focused, button-announced
image swallowed the gesture — a `transient` probe, and a resolved MIME the
viewer will not take.

This completes the four-branch matrix, each arm with a destination:

  - `ok` + allowlisted raster → the viewer, unchanged.
  - `ok` + anything else      → the options PANEL (DR-7). A REDIRECT, not a
    refusal: an SVG or a PDF referenced as an inline image is a real
    attachment with real options, it is just not something to hand a viewer
    that would execute it. The image therefore stays a real activation
    target and its accessible name names the panel — taking the semantics
    off (as the binary gate did) would hide a working control, and would
    make the redirect fire exactly once before the recorded MIME closed the
    gate on every later tap.
  - `missing` (authoritative 404) → the permanent placeholder, latched,
    nothing opened.
  - `transient` → the RETRYABLE placeholder. Never an open and never a
    latch: only a 404 is authoritative (DR-17).

The fence is hardened to the FULL address. The whole address is captured
before the await and compared after, and both emissions stamp the CAPTURED
values — the reader is live (`CommentEditor` is reused across an item
switch) so a re-read can address the wrong host. The continuation also
re-checks `deleted` on its own terms: a delete does not change which
attachment the node points at, so a probe issued before it can resolve `ok`
afterwards with the uuid still current. Check and emit stay adjacent and
synchronous — no timer, no microtask between them.

Also adds the minimal pending contract the await needs: `aria-busy` plus a
wait cursor while the MIME resolves, cleared by the resolution's finalizer
and by a uuid swap. No new chrome.

Seam with PLAN-2411, stated in comments and deliberately not built: the
`deleted` latch is cleared ONLY by an authoritative restore signal on
2411's channel, never by editor undo — DR-17 requires Ctrl-Z to leave an
inert placeholder rather than resurrect a working attachment.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
…426, TASK-2435)

The body editor fetched /server/capabilities and wrote the processor's
format list onto the extension — `ext.options.supportedFormats = …`.
Tiptap's `options` is a getter returning a fresh spread per access, so
that write landed on a temporary; the NodeView (which snapshots
`this.options` once at construction) never saw it, and rotate/crop sat
permanently in the degraded "no image processor" state.

Same root cause `$lib/attachments/hostAddress` exists for, so the same
shape: `supportedFormats` becomes a READER the host supplies, read at
the moment the toolbar gates on it. Editor.svelte closes over its own
capability state; the assignment is gone.

CommentEditor keeps transforms deliberately OFF — its reader is a
constant `[]`, not the server's list.

The new spec drives both REAL mount sites: a body toolbar built before
capabilities resolve snaps to correct per-format gating afterwards, one
built after is correct immediately, an unsupported format (image/tiff)
stays refused with the format-specific tooltip, and the comment
composer stays empty through the capability fan-out. Reverting the
reader to an assignment turns all four red.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
@xarmian
xarmian force-pushed the feat/attachment-viewer-unification branch from c290dd1 to 59289ba Compare August 6, 2026 16:53
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
@xarmian
xarmian force-pushed the feat/attachment-viewer-unification branch from 59289ba to 228f993 Compare August 6, 2026 17:03
xarmian added 2 commits August 6, 2026 17:22
…ne 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.
The final full-diff review found BUG-2441 surviving by a second route.
The per-event consumption mark cannot cover a HOLD: every auto-repeat
keydown is a FRESH event object, and by the second one the viewer's
lease is already released — so the event is unmarked and the owner
underneath acts. Holding Escape closed the viewer and then the sheet or
menu beneath it, from one physical press.

The two route guards already rejected `e.repeat` for exactly this
reason (with a comment saying so). `DockedSheet`, `BottomSheet` and
`TopBar` did not. They do now.

Regression tests come in the pair this file already establishes: the
BLOCKED case (a repeat must not close) and the EMPTY-STACK REGRESSION
(a repeat is ignored, but the next REAL press still closes) — the
second is what fails if a guard declines unconditionally, which is how
a deference change silently deadens a control. Mutation-verified:
removing the guard fails both, restoring it passes both.

Also corrects a stale comment in `attachments/events.ts` — `Lightbox`
imports `LightboxImage`, it does not re-export it.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
@xarmian
xarmian marked this pull request as ready for review August 6, 2026 20:33
@xarmian
xarmian merged commit 143d6dd into main Aug 6, 2026
7 checks passed
@xarmian
xarmian deleted the feat/attachment-viewer-unification branch August 6, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant