Commit Graph

690 Commits

Author SHA1 Message Date
xarmian 7b46894413 fix(e2e): silence cross-actor SSE creation toasts suite-wide (BUG-2334)
The e2e suite shares one pad instance and one workspace, so items seeded
by OTHER concurrently-running specs arrive over SSE and stack
"X created: ..." info toasts bottom-right — directly over bottom-right UI
(the graph drawer's detail card), turning unrelated specs' clicks into a
race. pane-content-link-anchors:238 paid a ~40-minute rerun tail at
nearly every merge gate.

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

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

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

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 15:43:42 +00:00
xarmian 837035e3ce fix(viewer): disarm failed promotion, gate degrade, rebase pinch on flip (PLAN-2392 3d fix round)
Three final-pass P2s in the attachment viewer's touch gestures:

1. FAILED-PROMOTION / STALE-OWNER: tryPromoteToPinch's missing-founder
   (!a || !b) path returned with the pan scalars still set — a stranded
   phantom pan that ate later gestures. It now fully disarms (release
   capture, clear scalars, drop the stale entry, disarm the tap, clear
   swallow) and re-arms the incoming touch as a fresh first touch,
   superseding the stale owner the way the first-touch reconcile does.
   The onTouchDown non-touch guard now requires the owner registry entry
   PRESENT, so a reconciled-out owner routes to that disarm instead of
   being misclassified as a live non-touch owner and swallowing the press.

2. GATES-ON-DEGRADE: the 2->1 degrade armed a survivor pan without the
   pointerGatesOpen check every START path carries — a native modal or
   stacked viewer opening mid-pinch left a pan that resumed when the layer
   closed. degradeToPan now gates on the leased viewer root and full-clears
   (before clearing `pinching`, so the held suppressClick drops too) when
   the gates are shut.

3. FLIP-MID-PINCH: the sheet class flips synchronously with the breakpoint
   while the ResizeObserver re-clamp is async, so an immediate post-flip
   move mixed the new stage rect origin with the old midpoint baseline and
   jumped the offset. onPinchMove now tracks the baseline's rect origin and
   re-seeds the midpoint (zero delta; scale is rect-independent) the moment
   the origin shifts, before the async re-clamp catches up.

Four discriminating tests (all fail on the reverted code); the 36-test V2
pinch suite + all existing suites stay green unmodified.

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 12:45:07 +00:00
xarmian cf85e8b8d3 feat(viewer): pointer registry + touch tap semantics (TASK-2517)
PLAN-2392 phase 3d V1 — the multi-pointer plumbing V2's pinch needs,
shipped inert. A `pointerId -> {x,y,type}` Map ('registry') tracks every
primary pointer (mouse, pen, touch); the mouse/pen drag keeps byte-
identical semantics as the 1-entry case (ownership still keys off
`gesturePointerId`, capture off `capturedPointerId`).

- Remove the `touch stays native` early-return gate; branch touch at the
  top of onPointerDown: touch ENTERS the registry but arms NO drag and
  takes NO capture. Taps still work — a touch tap falls through to the
  backdrop onclick (close), the chrome exclusion, and the deferred
  tap-to-load button (first-tap priority).
- Registry hygiene precedes every guard (round-2 P1): registry.delete
  runs FIRST in onPointerUp AND onPointerCancel, before any owner guard,
  so browser-claimed touches (which pointercancel routinely under
  touch-action:auto) never leak. abortGesture / onLostPointerCapture
  delete the pointer too; cancelGesture is restructured to clear the
  registry UNCONDITIONALLY (before its no-gesture early return).
- Reconcile a STALE armed owner (an off-root missed pointerup, pre-
  capture) out of the registry on the next superseding press, guarded so
  a same-id re-press never drops the entry it just set.
- The id-change effect deliberately does NOT touch the registry: nav
  doesn't change the physical pointer set.

Test inversion (falsify-don't-contort): the former ':2248' test pinned
"a touch pointerdown is IGNORED". It is replaced by the V1 contract,
split honestly into (a) touch press+move arms nothing / mouse byte-
identical and (b) a real no-move touch tap closes via the backdrop.
Added: registry drains on pointerup AND pointercancel (direct assert via
a test-only __registrySize accessor — the registry is inert in V1, so a
leak has no indirect observable), a pointercancel-storm-during-mouse-drag
leak/ownership test, a chrome-tap-inert test, a stale-owner
reconciliation test, and a touch tap-to-load first-tap-priority test.

NO touch pan, NO touch-action change, NO pinch in this task (V2).
Sheet-swipe-dismiss routed out of V1 as IDEA-2520.

Codex: 2 rounds, final CLEAN (round 1 caught the stale-owner leak + a
conflated inverted test; both fixed).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 11:24:12 +00:00
xarmian 3d191017ed fix(web): fence ordinary attachment loads against in-flight deletes
U2 (TASK-2511) added the ref-counted `inFlightDeletes` marker but applied
it only to `revalidateAfterRestore`'s merge. The ordinary list-load
reconciliation paths still filtered on `deletedIds` alone, and that set is
latched only AFTER a delete's API await (via the deletion bus self-
broadcast). In the gap between an optimistic removal and that broadcast a
row is gone from `attachments` but not yet tombstoned, so an ordinary
list() response issued (or in flight) across that window — a mount/retry
load, or the restore path deferring to an in-flight same-view load — could
carry the row and repaint the tile the user just removed.

Honor `isDeleting(id)` on every ordinary-load reconciliation path, exactly
as the restore merge already does: the response row filter, the pending-
upload merge, and the load-failure repaint. The settle-time rollback stays
a direct write into `attachments` (the marker is cleared in performDelete's
`finally`, after the catch re-inserts the row), so a genuinely failed
delete still rolls its row back into view. Continuation-count math is
unchanged in spirit — `rows`/`missed` simply exclude the same ids the
restore path already excludes.

Tests (jsdom, each mutation-verified): the P1 flow via a retry load, the
pending-upload merge leg, the load-failure repaint, and the rollback-
after-failure discipline surviving a list response that landed mid-delete.

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

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

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

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

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 04:38:37 +00:00
xarmian ffaba632cf test(web): pin attachment open-set mutation contracts (TASK-2513)
PLAN-2392 phase 3c-iii U4. Pin the open-set mutation contracts DR-15
style — assert the chosen behavior, don't assume it.

- Upload-during-open: a new REAL end-to-end test mounts the strip + the
  AttachmentSurfaceHost, opens the surface on a 2-image set, fires the
  upload bus, and asserts the open surface's set is unchanged (counter
  stays 1/2) while the strip's own tile list DOES gain the row. The two
  legs are independent: a dead upload bus fails the strip leg, a
  live-following surface fails the surface leg — neither masks the other.
  It pins the no-live-follow half; the in-place-mutation half stays pinned
  by events.test.ts's deep-snapshot test.

- Rename/metadata-change: no channel exists to exercise it (api.attachments
  has no rename/update-in-place op; transform mints a new peer row; metadata
  is immutable), so the contract is WRITTEN DOWN in the events.ts
  deep-snapshot doc rather than tested, and the future channel is routed to
  IDEA-2515.

Codex-reviewed (3 rounds): tightened the doc to scope claims to the event
channel, correct the api.attachments surface, and account for deletion
reconciliation + downstream metadata completion.

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

Two semantics pin the accounting:

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

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

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

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 02:53:43 +00:00
xarmian 08b6d94d30 feat(web): revalidate attachment strip content on parent restore (TASK-2511)
The item attachment strip has no SSE subscription — its only live inputs are
the in-process delete/upload buses — so a restore that happened while this
browser was elsewhere never reaches it, and its rows keep rendering from the
pre-archive fetch (thumb URLs work again by accident, metadata may be stale).

Thread `parentArchived` from ItemDetail (the same signal the timeline U1 and the
surface host DR-14 take, so it covers BULK archive/restore whose
`items_bulk_updated` carries no item_id) and reconcile the CONTENT gap
(PLAN-2392 3c-iii U2):

- RESTORE (true->false edge): `revalidateAfterRestore` re-fetches the attachment
  list and MERGES it over the current rows — a DIFFERENT, gentler path than the
  load effect's non-retry rerun, which blanks attachments/expanded/pendingDelete/
  deletedIds/pendingUploads synchronously. It never blanks (rows stay painted
  until replaced), preserves the tombstones / pending uploads / expanded-overflow
  / open confirmation the reset path would wipe, recomputes the continuation
  count off the fresh `total`, and clears a stale load error on success. A failed
  revalidation is swallowed (not surfaced as the blocking error row) — the strip
  already holds a good pre-archive list.

- ARCHIVE (false->true edge): a content no-op. Tiles keep their painted bytes;
  the interaction paths already fail server-side (DR-14's 404 correction).

Edge correctness:
- The archived latch is keyed to VIEW IDENTITY, not just the boolean: the strip
  persists across item switches, so an archived item A -> active item B is also a
  true->false transition — reseeding the latch on any view-key change keeps a
  SWITCH from firing a duplicate racing load (round-3 P2).
- `inFlightDeletes` (ref-counted, so a concurrent second delete of the same id
  can't be cleared early) excludes a row whose optimistic removal has run but
  whose tombstone broadcast — post-await in `performDelete` — hasn't yet, so a
  restore refetch landing in that window can't repaint the just-removed tile
  (round-3 P1).
- The revalidation DEFERS to a load already fetching THIS view (per-view counter,
  since the api client has no request abort and a stale prior-view load lingers):
  that load returns fresh-enough data, and superseding it would strand the strip
  empty if the revalidation then failed.

Latches are plain `let`s read/written under `untrack` (the Svelte self-write
trap). Adds the strip's `parentArchived` prop mount in ItemDetail. 22 new unit
tests; each guard mutation-verified.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 02:16:38 +00:00
xarmian e3d80619f9 feat(web): reconcile timeline attMeta across attachment lifecycle (TASK-2510)
ItemTimeline cached attachment HEAD-probe results in `attMeta` with no
invalidation path: a deleted attachment stayed a live `<img>`, an archived
parent kept painting a soon-to-be-broken image, and a restore never escaped a
`missing` cached while archived.

Add three reconciliation surfaces (PLAN-2392 3c-iii U1):

- Deletion bus subscription + per-id `tombstoned` set. A HEAD that resolves ok
  AFTER the delete can't repopulate `attMeta`; the tombstone is per-id so one
  deletion never false-fences another attachment's in-flight probe.
- A per-timeline lifecycle epoch captured at probe dispatch and checked before
  every authoritative write, bumped by both archive/restore edges — a
  pre-archive ok or pre-restore missing that lands after the edge refuses to
  write.
- A `parentArchived` PROP (threaded from ItemDetail, mirroring the surface
  host's DR-14 prop — NOT an SSE subscription, so it covers bulk
  archive/restore whose `items_bulk_updated` carries no item_id). While true,
  every probe goes through `revalidateAttachmentMetadata(..., {cache:'no-store'})`
  so a stale cached ok can't repaint a broken image and a genuine 404 lands as
  missing (the LEVEL rule). The false->true edge drops this item's tracked
  attMeta/probe state; true->false re-probes the unresolved set no-store via a
  reactive `probeNonce`.

Both lifecycle edges reconcile over the whole tracked set (referenced ∪ attMeta
∪ probed ∪ unresolved), not just currently-referenced ids, so an attachment
resolved-then-unreferenced can't replay a stale ok as a broken image or skip a
restore re-probe on a stale probe mark.

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian 5ee40d728f feat(attachments): mobile phone-sheet layout for the viewer (TASK-2492)
The AM-3 Lightbox-owned phone-sheet layout (PLAN-2392 3c-ii / T5). A
reactive `isSheet = $derived(viewport.isMobile)` toggles a `.lightbox-sheet`
class on the existing dialog root, and CSS scoped under that class re-lays-out
the EXISTING chrome into a bottom-anchored sheet: the toolbar and meta leave
their desktop absolute anchors and dock, stacked, to the bottom edge (via
`position: static` + `order`), the stage fills the space above (and becomes
its own containing block so its overlays centre over the shortened stage, not
the dock), and the counter moves to the top-left.

A class, not a bare `@media`, so JS and CSS share the one app breakpoint and
the flip is a DOM fact the modal-contract jsdom suite can drive and read. The
layout is fully layout-independent of the modal contract: the portal, lease,
focus trap, escapeStack registration, loader and zoom transform are untouched,
and nothing is keyed on the viewport, so a breakpoint flip mid-open re-lays-out
the SAME instance with zoom/selection state intact. No `BottomSheet`/`Menu`
instance nests, no swipe dismissal, and no `touch-action`/pointer-capture
changes (per the amended DR-6). Every rule is scoped under `.lightbox-sheet`,
so the desktop layout is byte-identical, and the sheet chrome carries its own
forced-colors boundary.

Tests: sheet selection, a mid-open flip proving same-instance re-layout (root
+ img identity, src + zoom survive), the docked chrome staying excluded from
all three pointer-gesture lists (pointerdown/wheel/dblclick, each with a live
control), no dismissal on a chrome click, and the full modal contract re-run
under the mobile viewport mock. Geometry, touch, `@media`/forced-colors visuals
are named for T7's browser legs.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian 44ee5ad806 feat(attachments): always-revalidate-on-open via per-open nonce (TASK-2491)
The converged attachment surface now revalidates the OPENED entry's metadata
on every open, so a cross-tab / background delete is caught rather than shown
as a live-looking row backed by a stale HEAD.

Mechanism: AttachmentSurfaceHost mints a per-open `openNonce`, incremented once
per accepted surface request and ridden on the request object into Lightbox (so
the `{#key request}` remount carries the matching nonce). The nonce joins the
metadata machine's SUBJECT identity (ws, attachmentId, nonce) and drives a
`forcedNonce` tracker that forces exactly one probe of the opened entry per open
— gated on the probe's own precondition (`isOpen && addressable`) so a not-yet-
open/addressable subject can't burn the nonce. Navigation keeps the nonce, so
arrowing does not force (3c-iii owns navigation-step revalidation). Unlike
`seenReload`, the nonce is deliberately NOT seeded from the incoming value: the
guarantee is to force on the first nonce seen.

The forced probe passes the literal `cache: 'no-store'` fetch option, threaded
through revalidateAttachmentMetadata -> fetchAttachmentMetadata -> fetch init,
so the endpoint's `max-age=3600` HEAD cannot serve a stale cached HEAD and
defeat detection. A `missing` result routes through the existing tombstone path.

Deliberate behavior change: the strip's zero-probe fast path is gone. A
complete-seed open previously issued no HEAD; it now issues exactly one forced
no-store revalidation (the displayed fields still come from the seed — seed-wins
merge — so the header is unchanged). A HEAD is not a byte fetch: the mobile
deferred cell's no-auto-bytes rule is untouched. The renderer load key does NOT
gain the nonce — a reopen is a whole new keyed mount, so cross-open coherence is
the remount's job and the nonce is constant within an open.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian af2a6ed57d refactor(attachments): retire the two legacy attachment channels (TASK-2490)
3c-ii T4b: with every producer already on the surface channel (T4a), delete
the two legacy channels and the cutover-window bridge. Gone from
`events.ts`: `notifyViewerOpen`, `notifyAttachmentPanelOpen`,
`ViewerReadyImage`, `ViewerOpenRequest`, `AttachmentViewerOpenEvent`,
`AttachmentPanelOpenEvent`, both legacy predicates
(`isAttachment{Viewer,Panel}EventForHost`) and registries
(`registerAttachment{Viewer,Panel}Listener`), plus the producer-boundary
MIME-drop gate (it lived inside `notifyViewerOpen`). The surface channel —
no admission MIME gate, renderer arm decides — is the sole open channel.

`AttachmentSurfaceHost` loses its two legacy subscriptions and the
`fromPanel` / `fromViewer` translators, and its `wsSlug` prop retires (the
surface channel captures its own workspace at emit); `ItemDetail` stops
passing it. The now-dead viewer-toolbar context props left by T4a
(`mutationsEnabled` / `getItemContent` / `getLiveContent`) delete from
`ItemAttachmentStrip` and `ItemTimeline` — the host forwards them to
`Lightbox` directly. Stale `AttachmentViewerHost` / panel-channel comments
updated across the touched files.

Tests: the legacy-channel unit + bridge tests delete with the channel; the
three host tests that exercised real surface behavior through a legacy event
re-point onto the surface channel (and the invoker + one-probe tests gain
discriminating assertions). Full suite green (1357), `npm run check` 0 errors.
Reviewed to a fresh-angle CLEAN over four Codex rounds.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian 72a7491dff feat(attachments): all six producers emit the surface channel (TASK-2489)
3c-ii T4a: every attachment producer now emits `notifyAttachmentSurfaceOpen`
with a captured `workspaceSlug`, its `invoker`, and its seeds — the two direct
`Lightbox` mounts (the strip's and the timeline's) are gone, and the ONE
`AttachmentSurfaceHost` owns every open. The bridge stays subscribed throughout,
so this window still ships a working route.

- Strip IMAGE tile (`openLightbox`): direct mount deleted; emits the raster
  `lightboxImages` set at the clicked index, invoker = the tile.
- Strip FILE tile (`openOptions`): panel → surface, a single-image set from the
  list row, invoker = the anchor. The flat seeds are normalized to match
  images[0] exactly (`filename || null`) — a blank `''` filename against a `null`
  record would fail the notify validator and silently drop the open.
- Timeline (`openLightboxFromImg`): direct mount deleted; emits the sibling
  `viewerImageFor` list, invoker = the img.
- Image NodeView: the raster/non-raster fork collapses to ONE surface emit after
  the (unchanged) resolve-before-emit gate — svg and raster both emit the same
  event, and the surface's own `getSurfaceRenderer` picks the arm.
- Chip: panel → surface single; `workspaceSlug` captured from the live address.

Both direct-mount producers gain a `paint.isCurrent()` STALE-ACTIVATION fence
they lacked: the strip reuses its existing paint fence; the timeline gains one (a
`viewIdentity` + `createPaintFence` recorded in an effect that tracks `entries`
and captures the view through `untrack`, so a bare workspace change that has not
yet reloaded keeps the old paint and refuses a stale click). The timeline's old
`lightbox`-clear-on-switch is retired — the host closes the open surface on the
resource switch now.

Tests: the nine producer suites migrate to the surface channel — each producer's
emitted workspaceSlug / invoker / seeds / index asserted, and the strip/timeline
opens asserted THROUGH a mounted `AttachmentSurfaceHost` (the real Lightbox end
to end). `viewerImagePayload`'s direct-mount premise is falsified and rewritten to
assert the emitted set. The svg cases assert one surface emit / the fallback arm
rather than "opens nothing". Housekeeping: the stale `AttachmentViewerHost`
comments in the touched files updated.

npm run test 1389 pass, npm run check 0 errors; the blank-filename open is
regression-pinned; reviewed to a fresh-angle CLEAN (round 1 caught the flat-seed
mismatch, round 2 clean).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian 9dc569c866 feat(attachments): the atomic cutover — one surface host, panel retires (TASK-2488)
3c-ii T2b, the commit users feel. ItemDetail's two attachment hosts collapse to
ONE `AttachmentSurfaceHost` at the top-level position, carrying the union prop
set (wsSlug for the legacy-panel bridge + itemId + hostToken + resourceGen +
mutationsEnabled + the content getters + parentArchived). BUG-2413's server
fail-closed disposition is on the base, satisfying the merge-gate.

Deleted: `AttachmentPanelHost`, `AttachmentViewerHost`, `AttachmentDetailsPanel`
(551 lines, + its CSS + its Menu/MenuItem usage). `AttachmentDeleteConfirm`
survives as the drill-down inside the surface. The panel's `closeAfterNavigation`
retires with it — the surface stays open after Open/Download, where the panel
closed itself.

Every open now flows through the one host and the file-capable Lightbox (T3):
a strip file tile → the fallback arm + file toolbar (through the legacy panel
channel + the bridge — producers repoint in T4a), a chip → the same, an image
NodeView → the raster arm, a non-raster redirect → the fallback arm.

Preserved three panel behaviors the cutover would otherwise have dropped (the
round-3 "what did the panel do that nobody ported" lens):
- A SINGLE-item surface whose file 404s shows the panel's inert "no longer
  available" overlay instead of flash-closing — `soleMissing` keys on
  `images.length === 1` (a panel open is always single), disposes the loader
  (no bytes) and keeps the toolbar inert; a MULTI-image set still advances /
  closes through the tombstone path, and an EXTERNAL bus delete still closes a
  single, exactly as the panel host did.
- The dialog's accessible name is the display name plus the header's type · size,
  not a bare alt.
- A null-filename file is named with the shared "Untitled file" fallback (the
  bridge uses `displayFilename`), not the Lightbox's bare "Attachment".

Test migration (named, not silently dropped): the extraction grep-gate →
`Lightbox.extraction.test.ts` (same contract, new consumer); the NodeView →
host → Lightbox whole-route test retargeted onto this host (the SVG redirect now
lands on the fallback arm); the three host suites consolidated into
`AttachmentSurfaceHost.svelte.test.ts` (lifecycle/addressing from T2a) and the
Lightbox suite (panel behavior), with a migration manifest naming what moved
where. Grep: zero PRODUCTION references to the three deleted components.

npm run test 1388 pass (Lightbox 190, host 22), npm run check 0 errors; the
single-item overlay + the aria-name changes are pinned; reviewed to a
fresh-angle CLEAN (round 1 caught the three dropped behaviors, round 2 clean).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian e0c06b0bdf feat(attachments): unified surface host with three-channel bridge (TASK-2487)
3c-ii T2a: `AttachmentSurfaceHost` — the one host that replaces the legacy panel
host + viewer host, mounting the grown Lightbox that opens ANY attachment. Built
COMPLETE but mounted NOWHERE: T2b does the atomic cutover (mount this, delete the
two legacy hosts + the panel), T4a repoints the six producers. Only its own suite
mounts it (grep confirms zero app-code references).

Three channels, one request (the bridge invariant). During the migration the
producers still emit on the two LEGACY channels, so the host subscribes to all
three (surface, panel, viewer) and TRANSLATES the legacy shapes INTERNALLY into
its own request state — it never calls `notifyAttachmentSurfaceOpen`. One `$state`
request → `{#key request}` → one Lightbox mount, so an accidental old+new double
emission for the same open supersedes rather than double-opens.

Translation, explicit: a legacy PANEL event becomes a single-open request with
`invoker = anchor` (never the live activeElement; no positioning), a one-element
images set from the seeds, and `workspaceSlug = the host's wsSlug prop` — the
transitional exception, since the panel channel carries no workspace (the new one
always does). A legacy VIEWER event maps field-for-field, keeping its captured
workspace and filling the flat seeds from images[index].

Lifecycle, ported from the two hosts and stated as the rule T3 deferred:
- Archive-closes / restore-revalidates, TRANSITION-based — which naturally splits
  archive-while-open (close) from open-while-already-archived (no transition → the
  surface mounts probe-gated INERT, not a flash-close: the user asked for the file,
  so show the inert "unavailable" state rather than blink it shut).
- Resource-switch clear on itemId change OR resourceGen advance (the complete rule).
- External-deletion close-when-SINGLE; a multi-image set is left to the Lightbox's
  own tombstone path (advance / close-last), never preempted by the host.
- closeRequest bound to its target (stale-continuation fence) and `request?.` guards
  on the lazily-read Lightbox props (a delete continuation reads them after the
  close nulled request).

Adds a minimal `revalidateToken` prop to Lightbox (threaded into the metadata
address, replacing the hardcoded 0) so restore re-probes an archived-at-open
surface; T6's always-revalidate-on-open openNonce layers onto the same input.

Tests: the three subscriptions; exact-once per channel (one legacy event → one
request → one mount → one probe → one focus return; old+new double emission does
not double-open); translation fidelity incl. the transitional-wsSlug and
captured-wsSlug cases; anchor→invoker for focused/null/disconnected; dual-host
addressing isolation; and the ported panel-host lifecycle (archive/restore/
item-switch/resourceGen/deletion). npm run test 1440 (host 21), npm run check 0
errors; the set-vs-single deletion guard mutation-verified; reviewed to a
fresh-angle CLEAN.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian af055a9bc9 feat(attachments): Lightbox admits any attachment — file route and reclassification (TASK-2486)
3c-ii T3: the image viewer becomes the converged surface that opens ANY
attachment. PURELY ADDITIVE — no producer routes files/archived to it yet (T4a),
so only the tests exercise the new capability and production behavior is
unchanged.

Admission flips (DR-20 final form). The last-mile filter that kept only
allowlisted-raster MIMEs navigable and REFUSED null/unsafe is gone, along with
the `unsafeAtOpenIds` snapshot: every entry is admitted, and safety moves to the
ARM. `shownRenderer` (and the toolbar's Open, and the fallback icon) derive from
the RESOLVED MIME — the seed's, or what the metadata machine's HEAD probe filled —
so `'raster-image'` mounts the `<img>` + bytes while a non-raster type (unsafe,
a file, or a still-unresolved MIME) mounts the no-bytes icon fallback. Admitting
unsafe/unresolved renders no hostile bytes: the arm fails closed on the resolved
MIME, joins the load key, and the loader is disposed off the raster arm.

Reclassification. A null-seed open shows the fallback until its probe answers,
then re-derives: raster → the image arm, PDF → fallback + Open, ZIP → fallback
without Open. The raster load hands the loader the RESOLVED mime
(`{ ...img, mime_type: resolvedMime }`) so its own DR-16 gate — which reads the
img it is given — agrees with the arm rather than refusing a null-seed row the
arm admitted.

Archived parent. `parentArchived` is a prop now (was hardcoded false), threaded
to the metadata machine so an archived-parent open forces a reachability probe
(DR-14). Every toolbar action is inert while `missing || unreachablePending`,
where `unreachablePending = parentArchived && (phase !== 'ok' || slow) && !missing`
— inert until a SETTLED ok, covering seeded, transient (slow-timeout), and a
forced re-probe after a prior ok. Disabled anchors drop their href (keyboard-inert),
not just aria-disabled. Threading the prop through the production hosts is T2a/T4a;
the archived lifecycle (archive-closes / restore-revalidates) lands with the host.

Tests: swept and rewrote the falsified 3c-i at-open-refusal pins to
admission+fallback assertions; added the file route (PDF Open present, ZIP absent),
delayed null-seed reclassification (raster/PDF/ZIP), and archived-parent gating
(pending / transient-stays-inert / ok→archived re-probe / re-enable-on-ok). The
3c-i unsafe-mid-view tests still pass. npm run test 1419 (Lightbox 190), npm run
check 0 errors; key invariants mutation-verified; reviewed to a fresh-angle CLEAN.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian da1b3f82ed feat(attachments): unified surface-open channel (TASK-2485)
The 3c-ii convergence's first task (T1): one bus channel for opening ANY
attachment on the grown Lightbox — image, file, or a row whose type is not yet
resolved — alongside the existing panel and viewer channels. Purely ADDITIVE:
the two legacy channels are untouched and nothing repoints yet (T2a builds the
host, T4a repoints producers, T4b deletes the legacy channels).

Adds `AttachmentSurfaceOpenEvent`, `notifyAttachmentSurfaceOpen`,
`isAttachmentSurfaceEventForHost`, and `registerAttachmentSurfaceListener` to
events.ts, matching the sibling channels' conventions (DR-8 addressing via
`isAddressable`, one host token per mount, same guard order and comment voice).

Differs from the viewer channel by design: NO admission MIME gate (files and
null-MIME/unresolved rows pass — the allowlist governs the render arm
downstream, never admission), and no `anchor` (the centered surface returns
focus via `invoker`). The event carries a CAPTURED-at-emit workspaceSlug
(required, no host fallback) and nullable single-attachment seeds that, when
present, describe images[index].

The emitter is the convergence boundary every producer will funnel through, so
it is hardened accordingly: it reads every input EXACTLY ONCE (event scalars,
the array length, each of the seven record fields), enforces the event's own
invariants at the boundary (index in range; images[index].id === attachmentId;
a non-null flat seed must agree with that record), and delivers a DEEP snapshot
built by explicit field projection — a fresh all-primitive record per entry and
an explicit event projection — so a caller that keeps mutating its set, a
getter/proxy TOCTOU, a shadowed `.map`, a stray property, or a non-string
identity field cannot reach an open surface. `invoker` is the one intentional
live reference (the focus target).

Tests (events.test.ts): predicate address isolation + null event; the capture
rule; null-MIME pass-through (the old gate's drop asserted ABSENT here); the four
boundary cases each their own test (out-of-range index, id mismatch, inconsistent
seed, deep snapshot of array AND records); plus record-integrity, projection, and
undefined-seed cases. cd web && npm run test (1410) + npm run check (0 errors)
green; key assertions mutation-verified; reviewed to a fresh-angle CLEAN.

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

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

Claude-Session: https://claude.ai/code/session_01VxyZv1g6W6rGx7nuaGcH3i
2026-08-10 13:38:37 -04:00
xarmian 7e85d1ce4e fix(attachments): viewer honors the missing metadata phase; comment refresh (PLAN-2392 3c-i)
Route the surface metadata machine's authoritative `missing` (404) phase through
the viewer's existing tombstone/advance path: an out-of-page delete (another tab,
a job, the API) never crosses the process-local deletion bus, but where the header
probes the shown image its HEAD returns `missing`, which is just as authoritative
(DR-17). A sentinel + untrack keep the effect CONVE-1688-safe and terminating
(a whole gone set cascades advance→advance→close, one 404 at a time). Only
`missing` latches; a `transient` stays retryable.

Fence `runToolbarAction` against the shown identity and reset `toolbarBusy`/
`toolbarError` on subject change, so a confirmed or slow delete of the shown image
that races the advance can't strand "Deleting…" or an error on the survivor now
on screen.

Refresh the final-state comments: the viewer is now the second consumer of the
shared action list; the viewer host carries live `mutationsEnabled` since C1 while
the open channel stays permission-free; the deletion bus is process-local, and
the missing path's probe-scope (strip images seed mime+size and aren't re-probed)
is stated where it matters.

Tests: missing→advance, missing-only→close, whole-set cascade→close, transient→
no-op, and shown-delete-while-confirm-up→clean-toolbar (mutation-verified against
the reset).

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

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

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

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

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

https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 18:39:33 +00:00
xarmian 10b99dec2e feat(attachments): viewer deletion subscription — advance or close by identity (TASK-2477)
The image Lightbox subscribes to the deletion bus and reconciles a delete by
IDENTITY (DR-5c): the shown image is tracked by id, the index derived, so a
delete advances to a survivor or closes — never lands on a position that now
names a different member.

- The index-based `current` state is replaced by `shownId` (the shown image's
  id) + a `tombstones` Set. `survivors` = navigable minus tombstones (composes
  with D's unsafe/unresolved exclusions — a fallback-arm entry is deletable too);
  `shownIndex` derives from `shownId` (falls to 0 when it dangles); prev/next
  write `shownId`. Tombstones are per-instance and never reset — every producer
  keys the mount, so a reopen is a fresh empty set (no cross-open leakage).
- handleDeletion(uuid): idempotent; a delete NOT in the surviving set (unsafe/
  unresolved, another item's attachment, a dangling shown id) is tombstoned and
  ignored (no advance, no closing an already-empty viewer). An in-set delete
  advances `shownId` to the entry that followed the deleted one (wrap when the
  last) when the SHOWN one went, closes when zero survive, and leaves `shownId`
  put otherwise (identity, not index — deleting an earlier image keeps the same
  one shown). The id-keyed zoom-reset fires exactly on a real advance.
- ONE path for both origins: the toolbar's own Delete announces on the bus (the
  descriptor's `announceAttachmentDeleted`), identical to an external delete, so
  the survivor logic can't tell them apart. The C1 close-on-delete latch is
  retired (the viewer had no survivor logic then); the toolbar ctx now omits
  `onDeleted` (that stays the panel's). The listener is disposed in the same
  teardown that releases the backdrop lease.

Tests: the DR-5c case table (only/zero-left → close, one-left, deleting-shown →
advance, wrap-around, deleting-earlier → same image by identity, reopen clears
tombstones), plus delete-during-drag, delete racing the confirm drill-down, a
non-image fallback delete, the dangling-id / empty-viewer guard cases, and a
two-image toolbar integration in the strip suite (confirm → api → announce →
advance). The strip's events mock now fans `announceAttachmentDeleted` out to
listeners, matching production.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 17:23:29 +00:00
xarmian c6133af961 feat(attachments): content slot with icon fallback and no-bytes arm (TASK-2476)
The image Lightbox stage becomes a two-arm slot governed by a defined renderer
seam. A mid-view unsafe entry now shows a NO-BYTES icon fallback instead of
being dropped, while the 3a open gate (unsafe/unresolved AT OPEN → refused) is
held through 3c-i.

- NEW surfaceRenderers.ts: getSurfaceRenderer(mime): SurfaceRendererId | null —
  'raster-image' for exactly the DR-16 raster allowlist (wraps canOpenInViewer,
  one source of truth), null otherwise. The id union is PLAN-2393's seam.
- The old canOpenInViewer last-mile FILTER is replaced by: unsafeAtOpenIds (a
  mount-time snapshot of resolved-unsafe ids — the open gate, held); navigable
  (keeps safe + mid-view-unsafe, drops unresolved + unsafe-at-open); shownRenderer
  (the stage arm). Behavior matrix per PLAN-2392: safe → img arm; unsafe-resolved
  → refused at open / fallback mid-view (new — was dropped); unresolved → refused;
  added-while-open unsafe → fallback, unresolved → refused. Fallback entries are
  navigable + counted.
- THE NO-BYTES INVARIANT: the fallback arm mounts no <img>; the load effect
  DISPOSES the loader on any non-raster arm; loadKey joins shownRenderer so a
  same-id safe→unsafe flip re-fires the effect and reaches the dispose; a
  releaseImg action clears the detached <img>'s src on unmount (aborts the
  native request rather than leaking it to GC); bitmapPresent is gated on the
  raster arm so zoom/pan/keys are disabled on the fallback; a reactive
  cancelGesture tears down a live drag the instant the bitmap vanishes (plus a
  pointerup bitmapPresent guard). The loader's own start() DR-16 gate is the
  backstop. Focus re-homes across the arm swap (the handoff effect tracks the
  arm).
- The fallback arm renders the large family icon, the display name (full value
  in title, DR-13), type · size, and "No preview available"; same chrome, modal
  contract and lease as the raster arm.

Tests: the behavior matrix rewritten cell-by-cell (unsafe mid-view → fallback,
at-open unsafe/unresolved → refused with no fallback asserted), the no-bytes leg
(no <img>, detached src cleared, loader disposed), fallback navigable/counted,
zoom disabled, focus re-homed, the reactive drag-cancel, and getSurfaceRenderer
units. The generation fence stays covered by viewerImageLoader's unit tests.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 16:47:12 +00:00
xarmian 9cf86cb8c2 feat(attachments): viewer metadata header (TASK-2475)
The image Lightbox gains a filename / type / size header for the shown image,
seeded from the LightboxImage and completed by the TASK-2473 surfaceMetadata
(B) module — open immediately, fill what is null after (DR-2).

- Display-name chain: filename ?? alt ?? 'Attachment'. A blank/whitespace
  filename is normalized to null once, at ingestion (the viewable .map), so the
  chain falls through to alt rather than rendering an empty string — this also
  fixes the pre-existing C1 case where "" ?? alt yielded "". The chain is shared
  by the header, the download attribute and the delete prompt (displayFilename
  is retired from the viewer).
- Type via describeAttachmentType only when MIME is known (a viewer image always
  cleared the MIME gate, so it always shows); size via formatBytes only when
  size_bytes is a number — never fed null, absent beats "0 B". Seed values win;
  the fetch fills nulls but never overwrites a non-null seed. The fetch fires
  iff size is null (the HEAD never returns a filename), through the B module.
- A transient failure renders the DR-10 inline "Couldn't load details · Retry"
  BESIDE the name it already knows; Retry revalidates (never replays). Retry
  unmounting on success re-homes focus into the viewer (the focus-handoff effect
  tracks the transient flag).
- Bottom-left, clear of the top toolbar/Close (DR-18); a label like the counter
  (excluded from pan/zoom via .lightbox-meta in all three gesture lists, does
  not close), width capped to clear the centered counter in LTR and RTL. DR-13:
  truncated with the full value in title, logical properties, min-width:0.

Tests: the precedence chain across every null/blank/whitespace combination, the
size-null fetch fill + seed-wins merge (exact, distinct fetched MIME), the
transient retry + focus re-home, and the 200-char full-value preservation. The
strip/timeline producer mocks now export the metadata functions the mounted
Lightbox needs.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 15:48:02 +00:00
xarmian c0ec3adc4b feat(attachments): viewer toolbar with host-supplied context on every mount (TASK-2474)
The image Lightbox gains an action toolbar rendered from the shared
`attachmentActionsFor(ctx)` descriptor list (DR-5) — anchor/button per the
`element` discriminant, icon-only on desktop, labeled on phone (DR-18). Delete
reuses TASK-2473's shared confirm module: the module owns the confirmation GATE
(request()), the descriptor (actions.ts) still owns the delete + announce.

Context threading — one vocabulary at every Lightbox origin:
- Lightbox gains mutationsEnabled (default false → read-only toolbar),
  getItemContent, getLiveContent.
- AttachmentViewerHost, ItemAttachmentStrip and ItemTimeline each gain and
  forward the same three to their direct Lightbox mount.
- ItemDetail supplies all three from its own values: mutationsEnabled is
  canEdit && !peeking (NEVER the timeline's own canEdit, which ignores peeking),
  so a peeked pane's viewer shows no Delete.
- actions.ts AttachmentActionSubject.mime_type relaxed to string|null; Open
  needs a positively-previewable MIME (null → not offered), the rest ignore it.

Toolbar integrates with the 3b gesture machinery: registered in the pointerdown,
double-click AND wheel control-exclusion lists so a press/dblclick/wheel over it
never pans or zooms; z-index within the <100000 viewer sweep. The delete
drill-down is a role="menu" of role="menuitem" rows with roving tabindex (arrows
navigate, Tab exits to chrome), focus enters the first row on open, Escape backs
out before closing the viewer, and image-nav is suppressed while it is up. A
confirmed delete closes the viewer (guarded on identity + a destroyed latch); a
subject change or set-shrink abandons a pending confirmation.

Tests: toolbar renders on all three origins; the peeked-pane leg (timeline
canEdit true, mutationsEnabled false → no Delete); anchor fidelity; delete
drill-down + permission-withdrawn abandon; and the review-hardening cases
(wheel exclusion, role=menu roving, Escape back-out, image-change abandon).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 14:40:27 +00:00
xarmian 9c40a5f141 refactor(attachments): extract surface metadata + delete-confirm machines (TASK-2473)
Lift AttachmentDetailsPanel's metadata machine and delete-confirmation
machinery verbatim into two shared Svelte 5 rune modules so the panel and
the converged image viewer (phase 3c-i) share ONE implementation:

- surfaceMetadata.svelte.ts — createSurfaceMetadata(address, {onSubjectChange}):
  owns the three (workspace, attachment) fences, the seed-then-HEAD metadata
  effect, retry (invalidate-then-fetch), dispose, and the phase/fields/slow
  getters a renderer branches on.
- surfaceDeleteConfirm.svelte.ts — createDeleteConfirm(deps): owns the
  'root'|'delete' sub-view state, the pending resolver, and the
  permission-withdrawn abandon effect. Owns the confirmation GATE only; the
  delete descriptor (actions.ts) still owns the delete itself via
  ctx.confirmDelete, preserving its identity snapshot + permission re-check.

The panel becomes a consumer + renderer (322 lines lighter). Behavior is
unchanged: the 28-test AttachmentPanelHost oracle passes UNMODIFIED, and a
new grep-gate (AttachmentDetailsPanel.extraction.test.ts) proves the panel no
longer defines the machinery locally.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 13:35:05 +00:00
xarmian 4f57a974c7 feat(attachments): action icons — MenuItem snippet path, actions.ts to SVG ids (TASK-2472)
Phase 3c-i task A (PLAN-2392). The attachment action descriptors carried
glyph strings (⇗ ⇩ 🔗 🗑) rendered as MenuItem's text `icon`; this moves
them to the shared monochrome SVG icon set so the toolbar the next tasks
build renders real, forced-colors-safe icons.

- The icon registry (attachments/icons) gains four ACTION icons —
  action-open, action-download, action-copy-link, action-delete — in the
  same inline-path, currentColor, stroke-only style as the file-family set,
  so both render paths (AttachmentIcon.svelte, iconSvg) draw them from one
  table. New ACTION_ICON_IDS + the IconId union (family ∪ action); a broad
  isIconId narrow (the family-only isAttachmentIconId stays for MIME→family
  mapping) so iconSvg renders action ids while keeping its generic fallback
  for unknown strings.
- The action descriptor's `icon` field narrows from string to ActionIconId,
  and the four glyphs become the ids. AttachmentIcon's `id` widens from
  string to IconId — an unknown id is now a compile error rather than a
  silent generic-file fallback.
- The details panel renders each action's icon through MenuItem's
  `iconSnippet` with AttachmentIcon (a per-iteration snippet closing over the
  row's id), not the text `icon` prop. MenuItem's `.mi-icon` slot centres its
  content on both axes so the block SVG isn't left-aligned — text/emoji
  consumers unchanged.
- Two stale comments corrected (the "toolbar arrives in phase 3a" note → 3c-i).

Falsifiable acceptance: a test asserts every panel action row renders an SVG
and none of the four glyph characters survive (fails if the panel passes
strings — mutation-verified); a drift-guard asserts the four action ids exist
in the registry and render non-empty paths. Out of scope (3c-ii): the delete
confirm's own rows.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 13:05:21 +00:00
xarmian d4da83d2cd fix(attachments): final-pass fixes — re-clamp on same-id reload, inert error state (PLAN-2392 3b)
Three fixes from the final full-diff review of phase 3b.

1. RE-CLAMP on a same-id decode. `loadKey` includes the pixel dimensions, so
an async dimension fill re-runs the load — but the reset effect keys on
`img.id` only and the ResizeObserver watches only the stage, so neither
re-clamps the transform. The concrete failure: the unknown-dims desktop cell
loads the original; dims fill; the policy switches to thumb-md; the thumb's
smaller natural size lowers actualScale and MAX_SCALE, stranding the scale
above the new ceiling with out-of-bounds pan. Re-clamp (clampState + rebaseDrag
— not a reset; a valid zoom survives) in the <img> onload handler, where
geometry is first measurable, fenced to the live element (el === imgEl) so a
stale detached decode can't drive the current zoom.

2. INERT error state. `errored()` flips only the phase and leaves `displaySrc`
set, so the broken <img> still satisfied readGeometry and every gesture entry
point acted over the error UI with nothing decoded. `bitmapPresent` now also
requires `phase !== 'error'`, and the wheel, double-click and live drag-move
paths (which read geometry directly) bail on it too — a drag armed before the
error aborts on its next move. All re-enable after a successful retry.

3. Three stale doc comments updated to the assembled contracts: the Lightbox
header no longer claims original-only loading, and the "upload event carries
only four fields" notes in Lightbox and ItemAttachmentStrip now reflect
UploadedAttachment carrying the pixel dimensions since TASK-2459.

Three new mutation-checked unit tests (each fails when its fix is reverted);
zoom e2e spec re-run green on both projects.

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

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

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

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

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 06:27:25 +00:00
xarmian e79a9bbb95 feat(attachments): DR-5b mobile policy — tap-to-load and fetch-on-zoom-past-fit (TASK-2460)
Own the mobile cells of the DR-5b decision table (the classifier landed
in TASK-2459). Add a `deferred` loader phase for the mobile large/unknown
cell — no automatic request; the viewer shows a tap-to-load placeholder
sized to the image's aspect ratio (a neutral box when dimensions are
unknown), with explicit `pointer-events: auto` and focus-handoff since it
is replaced by the image it loads. A single deduplicated `loadOriginal`
serves BOTH triggers — the tap (deferred cell) and zoom-past-fit
(`scale > 1`, the painted thumb cell) — so a tap racing a zoom, a retry,
or a later pinch is one fetch, not two.

The fallback detector now runs on every first decode, desktop and mobile:
when `thumb-md` was served the original (fresh upload, WebP/AVIF), it
clears the deferred original so zoom-past-fit cannot re-download bytes
already held. The zoom-past-fit trigger gates on a `painted` flag (tracked,
never written by loadOriginal, so no self-write) — a zoom made before the
thumb paints upgrades the instant it does, rather than stranding the user
on the thumbnail. Zoom is disabled, not just no-op, while nothing is
decoded: the zoom keys short-circuit and drag no longer arms/captures over
the deferred placeholder. Retry after an on-demand original failure
re-requests the original directly rather than reverting to the affordance.
Breakpoint flips do not retro-fetch (platform is captured at load). Every
idle cleanup funnels through one `toIdle()` so the `painted === false in
idle` invariant can't drift, and every request edge restates the DR-16
gate.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 06:27:25 +00:00
xarmian 7826866728 feat(attachments): DR-5b classification and desktop thumb-then-original loading (TASK-2459)
Add the memory-safety half of DR-5b: a pure pixel classifier
(viewerLoading.ts) and a loader lifecycle (viewerImageLoader.svelte.ts)
that paints a bounded thumb-md first and background-upgrades to the
original on desktop, while a phone auto-pulls nothing (TASK-2460 tap).

Classification is on pixels never bytes; null width OR height is a third
'unknown' class, not an alias for large. The fallback detector keys off
the decoded long edge exceeding THUMB_LONG_EDGE (a thumb-md is bounded at
1024 by definition), so a thumb request silently served the original
(fresh upload, WebP/AVIF) is caught without mirroring the server decoder
set; the unknown-dims desktop cell requests the original directly since a
bounded fallback would defeat the detector.

Wire it into Lightbox: the <img> is keyed on a per-request loadToken (a
fresh element per load/retry; reused across the thumb->original upgrade),
DR-10 spinner + retryable error with an explicit pointer-events:auto retry
that hands off focus before it disappears, and DR-16 as a loading gate at
the request chokepoint (no request for unsafe/unresolved MIME). Staleness
is fenced by a generation captured as a frozen data-gen DOM attribute plus
the src, so a detached element's late load/error in an A->B->A navigation
(same URL) can't clobber the live image. The load key includes dimensions
so an async metadata fill re-runs the policy. Thread the width/height
toUploadedAttachment used to drop.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 06:27:25 +00:00
xarmian 90c5992600 feat(attachments): double-click zoom toggle and drag-to-pan (TASK-2458)
Desktop single-pointer gestures for the attachment viewer (3d keeps two-pointer
pinch, double-tap and touch semantics), modelled on the captured-drag house
pattern in graph/ItemGraph.svelte.

- Double-click toggles fit <-> actual size, anchored at the pointer
  (toggleFitOrActual). Excludes presses/double-clicks on the close/nav controls,
  and stands down while a pan's click is being suppressed.
- Primary-button pointer drag pans while zoomed, clamped by TASK-2454. Arm on
  pointerdown (capture only once the 4px threshold is crossed, so dblclick
  survives); the pan is origin + total delta, rebased whenever an external zoom
  (wheel / +/-/0 keys / resize) moves the transform mid-gesture so it never
  snaps. draggable=false + user-select:none stop native image-drag / text
  selection; the transform transition is dropped while dragging so the image
  tracks the pointer.
- Drag-vs-click: a below-threshold press still closes on the backdrop; a
  past-threshold drag suppresses its synthesized click (owned single timer); a
  plain backdrop click still closes.
- Arbitration: every pointer entry point (drag start, double-click, backdrop
  click) carries onKeydown's isViewerFrontmost / !isBlockedByModal gates, and
  the WHOLE gesture re-checks on every move — a drag that straddles a
  stacked-viewer or native-modal transition aborts (releases capture, leaves the
  transform). The gesture is owned by its pointerId, so a touch / second pointer
  can't engage, hijack, or terminate a live mouse drag; state fully resets so no
  gesture leaks into the next pointerdown.

Tests cover the positive-pan-then-clamp acceptance, two anchored double-clicks
returning (realistic click/click/dblclick on the image), all three
disambiguation legs, stacked-viewer AND native-modal aborts mid-capture, and the
gesture-hygiene edges (wheel/keyboard-mid-drag rebase, buttons-released teardown,
lostpointercapture, stale-arm, pointer ownership, transition-off-while-dragging).
2026-08-08 06:27:25 +00:00
xarmian ab340863a6 feat(attachments): pointer-anchored wheel zoom; restoration ignores viewer input (TASK-2457)
Wheel zoom for the attachment viewer plus a scroll-restoration guard so the
viewer's own input can't strand a page restore underneath it.

Lightbox:
- Plain AND ctrl/cmd wheel zoom (DR-4) via TASK-2454's zoomTo, anchored at the
  cursor (stage-local coords; the stage is untransformed). Registered
  imperatively on the viewer ROOT with { passive: false } so preventDefault
  works — the inert page must not scroll and ctrl/cmd+wheel must not trigger the
  browser's page zoom — and so a wheel over the backdrop is consumed too. Also
  stopPropagation (belt to the restoration guard). Same frontmost /
  blocked-by-modal gates as onKeydown. A horizontal-only wheel (deltaY 0) is
  consumed but does not zoom.

Scroll restoration (shared route infra, scroll/restore.svelte.ts):
- Its passive wheel/touchmove/keydown listeners aborted a pending restore on ANY
  such event — so the viewer's own arrow-nav (shipped 3a) stranded a restore
  today, and wheel-zoom would too. New exported isModalViewerScrollInput ignores
  events that are defaultPrevented OR originate inside the frontmost viewer;
  generalized across wheel/key/touch (touch stays native until 3d, so it is not
  defaultPrevented and only the origin check catches it). Purely additive to the
  user-input branch — BUG-1425's anchor handling / one-shot / budget untouched.
  A genuine non-viewer scroll still aborts, as before.

Tests: wheel anchor asserted against the module (nonzero stage offset), direct
preventDefault + stopPropagation, frontmost/blocked no-op each with a positive
control, horizontal-wheel no-op (seeded so a spurious zoom-out is observable);
restore integration tests drive a real restore and assert a viewer-originated
wheel/key/touch does NOT abort it while a genuine event does, plus
isModalViewerScrollInput unit tests. New fixture RestoreHarness.svelte.
2026-08-08 06:27:25 +00:00
xarmian 07b9a0430e fix(attachments): hand off focus before a viewer control disappears (TASK-2456)
The attachment viewer claims aria-modal but dropped focus to <body> behind
its own inerted app when a focused, conditionally-rendered control unmounted:
when a MIME resolves unsafe and the set shrinks to one, the focused 'Next
image' button is removed and nothing moved focus (the Tab trap only repairs on
a later Tab; the restore only runs at teardown). A pre-existing 3a defect that
every control 3b adds inherits.

- New exported helper paneFocus.ts::handoffFocus keeps focus inside a modal
  surface when a focused control leaves it. Two shapes: reactive (no departing
  arg — repair after a Svelte {#if} drops the control, within the same flush)
  and imperative (pass the departing control — blur it and hand off BEFORE the
  caller removes/disables it, the house pattern from attachment-image.ts). The
  departing control is excluded from fallback candidates so it is never
  re-selected and then dropped on disable; the fallback (first tabbable, else
  the tabindex=-1 container) is verified to have taken focus, else the container
  backstops it. Shaped for TASK-2459 retry / TASK-2460 tap-to-load reuse.
- Lightbox wires it via an $effect keyed on the nav-visibility signal, guarded
  to the frontmost, non-blocked viewer so a background viewer can't steal focus.
  Reads only derived/element state and mutates DOM focus (no $state) — CONVE-1688 safe.

Tests: handoffFocus unit tests (reactive/imperative/disable/only-tabbable/
inert-refusal/no-op) with an explicit without-handoff control leg; Lightbox
same-instance shrink handoff, background-viewer non-theft, and Tab-cycles-at-
max-zoom.
2026-08-08 06:27:25 +00:00
xarmian e334e86340 feat(attachments): wire zoom transform into Lightbox (TASK-2455)
Wire TASK-2454's pure zoom/pan math into the attachment viewer:

- Stage + transform wrapper around the <img> per the module's coordinate
  system (92vw x 92vh stage, object-fit: contain image carrying
  translate/scale). pointer-events: none on the stage lets letterbox
  clicks reach the backdrop; the image re-enables them; controls sit
  above via z-index (kept < the viewer's 100000 sweep bound).
- +/- zoom about the stage centre, 0 resets, all INSIDE the existing
  onKeydown gates (defaultPrevented / isViewerFrontmost / isBlockedByModal).
  Ctrl/Cmd/Alt are left untouched (no act, no preventDefault) so browser
  page-zoom and OS shortcuts keep working; bare =, shift, and numpad forms
  accepted.
- Transform resets when the shown image changes (arrow nav, or the set
  shrinking under current), keyed on image id via a plain-let sentinel so
  no $effect reads a $state it also writes (CONVE-1688).
- ResizeObserver on the stage re-clamps SCALE first, then pan
  (clampState), since enlarging the viewport lowers the geometry-dependent
  MAX_SCALE.
- Global inert ResizeObserver shim in setup-jsdom.ts; reduced-motion
  suppresses the transition only.

Adds zoom-key, arbitration, reset-on-navigate, resize-clamp and
centre-anchor tests to Lightbox.svelte.test.ts.
2026-08-08 06:27:25 +00:00
xarmian 27de6221c4 feat(attachments): add pure zoom/pan math module (TASK-2454)
The DOM-free half of PLAN-2392 phase 3b's desktop zoom: scale/translate
state, anchored zoom, pan clamping and the fit<->actual toggle, with no
imports at all so the math is testable without layout. jsdom reports
all-zero rects, so anything that reads geometry is unprovable there —
keeping the numbers in a pure module is what makes them assertable.

Scale is relative to fit, so FIT is the constant 1 rather than a derived
quantity: the CSS (92vw/92vh + object-fit: contain) stays the fit engine
and the transform multiplies from there, which keeps the module and the
stylesheet in ONE coordinate system. clampState composes scale-then-pan
in that order because a resize can lower MAX_SCALE beneath a currently
valid scale, and clamping pan first would bound it against a scale that
is about to change.

Reviewed adversarially in three rounds. Round 3 took the numerical
robustness angle and found two real defects, both fixed here and both
pinned by tests that fail against the pre-fix code:

- extent * scale could overflow to Infinity at extreme finite geometry,
  making the pan bound infinite and silently disabling the clamp — the
  one failure mode that lets blank stage show past an image edge.
- a subnormal fitted extent divided to an infinite actualScale, which
  zoomTo's clampScale then rejected as invalid and replaced with fit,
  turning the double-click toggle into exactly the silent no-op that
  TOGGLE_SMALL_SCALE exists to prevent.

The fit-epsilon boundary is asymmetric (FIT + EPSILON reads as at-fit,
FIT - EPSILON does not, a few ulps of binary rounding). Left as is and
documented rather than papered over: every emitted scale has been
through clampScale, whose floor is FIT, so a sub-fit state cannot arise.
Both legs are pinned so a future change to the floor cannot make it
matter silently.

Also deliberately generalises the spec's "when actualScale === 1" rule
to "within FIT_EPSILON of 1". At fractional devicePixelRatio a 1:1 image
measures 1.0000000004, and strict equality makes the toggle a no-op in
precisely the case the rule exists to prevent.

88 unit tests; 23 hand-built mutants killed. npm run check clean;
full web suite 1180 tests green.
2026-08-08 06:27:25 +00:00
xarmian 0a547283db fix(deps): keep @dagrejs/dagre pinned at 3.0.0
The group bump to 3.1.0 reproducibly breaks the graph-pane anchor E2E
(pane-content-link-anchors.spec.ts:238, node click timeout, 3/3 runs) —
layout changes move the node hit-target. dagre was exact-pinned at 3.0.0
by the renderer's author (TASK-1783); keeping it that way. Follow-up for
the 3.1 upgrade tracked separately.

Claude-Session: https://claude.ai/code/session_01RNcrc3CtXwJwreubtHTgN6
2026-08-06 23:52:06 +00:00
dependabot[bot] 3f0b390288 chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 24 updates
Bumps the npm-minor-and-patch group with 23 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [@dagrejs/dagre](https://github.com/dagrejs/dagre) | `3.0.0` | `3.1.0` |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.22.5` | `3.29.2` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.22.5` | `3.29.2` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.22.5` | `3.29.2` |
| [@tiptap/extension-collaboration](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration) | `3.22.5` | `3.29.2` |
| [@tiptap/extension-collaboration-caret](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration-caret) | `3.22.5` | `3.29.2` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.22.5` | `3.29.2` |
| [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) | `3.22.5` | `3.29.2` |
| [@tiptap/extension-task-item](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-item) | `3.22.5` | `3.29.2` |
| [@tiptap/extension-task-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-list) | `3.22.5` | `3.29.2` |
| [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.22.5` | `3.29.2` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.22.5` | `3.29.2` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.22.5` | `3.29.2` |
| [@tiptap/y-tiptap](https://github.com/ueberdosis/y-tiptap) | `3.0.3` | `3.0.8` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.12` | `3.4.13` |
| [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) | `0.9.74` | `0.9.77` |
| [yjs](https://github.com/yjs/yjs) | `13.6.30` | `13.6.31` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.61.1` | `1.62.1` |
| [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) | `2.70.1` | `2.70.2` |
| [@sveltejs/vite-plugin-svelte](https://github.com/sveltejs/vite-plugin-svelte/tree/HEAD/packages/vite-plugin-svelte) | `7.1.2` | `7.2.0` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.55.8` | `5.56.8` |
| [svelte-check](https://github.com/sveltejs/language-tools) | `4.7.3` | `4.7.4` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.5` | `8.2.0` |



Updates `@dagrejs/dagre` from 3.0.0 to 3.1.0
- [Release notes](https://github.com/dagrejs/dagre/releases)
- [Changelog](https://github.com/dagrejs/dagre/blob/master/changelog.md)
- [Commits](https://github.com/dagrejs/dagre/compare/v3.0.0...v3.1.0)

Updates `@tiptap/core` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/extension-bubble-menu)

Updates `@tiptap/extension-code-block-lowlight` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-collaboration` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-collaboration/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/extension-collaboration)

Updates `@tiptap/extension-collaboration-caret` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-collaboration-caret/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/extension-collaboration-caret)

Updates `@tiptap/extension-link` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-table` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-table/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/extension-table)

Updates `@tiptap/extension-task-item` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/extension-task-item)

Updates `@tiptap/extension-task-list` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/extension-task-list)

Updates `@tiptap/pm` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/pm)

Updates `@tiptap/starter-kit` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.22.5 to 3.29.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/suggestion)

Updates `@tiptap/y-tiptap` from 3.0.3 to 3.0.8
- [Changelog](https://github.com/ueberdosis/y-tiptap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/y-tiptap/commits)

Updates `dompurify` from 3.4.12 to 3.4.13
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.12...3.4.13)

Updates `svelte-dnd-action` from 0.9.74 to 0.9.77
- [Changelog](https://github.com/isaacHagoel/svelte-dnd-action/blob/master/release-notes.md)
- [Commits](https://github.com/isaacHagoel/svelte-dnd-action/commits)

Updates `yjs` from 13.6.30 to 13.6.31
- [Release notes](https://github.com/yjs/yjs/releases)
- [Commits](https://github.com/yjs/yjs/compare/v13.6.30...v13.6.31)

Updates `@playwright/test` from 1.61.1 to 1.62.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.1...v1.62.1)

Updates `@sveltejs/kit` from 2.70.1 to 2.70.2
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/version-3/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.70.2/packages/kit)

Updates `@sveltejs/vite-plugin-svelte` from 7.1.2 to 7.2.0
- [Release notes](https://github.com/sveltejs/vite-plugin-svelte/releases)
- [Changelog](https://github.com/sveltejs/vite-plugin-svelte/blob/main/packages/vite-plugin-svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/vite-plugin-svelte/commits/@sveltejs/vite-plugin-svelte@7.2.0/packages/vite-plugin-svelte)

Updates `svelte` from 5.55.8 to 5.56.8
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.8/packages/svelte)

Updates `svelte-check` from 4.7.3 to 4.7.4
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.7.3...svelte-check@4.7.4)

Updates `vite` from 8.1.5 to 8.2.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite)

---
updated-dependencies:
- dependency-name: "@dagrejs/dagre"
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.70.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@sveltejs/vite-plugin-svelte"
  dependency-version: 7.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/core"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration-caret"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-table"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-item"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-list"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/pm"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/y-tiptap"
  dependency-version: 3.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.56.8
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-check
  dependency-version: 4.7.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-dnd-action
  dependency-version: 0.9.77
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: yjs
  dependency-version: 13.6.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 23:12:41 +00:00
xarmian 124ebf8cef fix(a11y): stop a HELD Escape cascading past the viewer (TASK-2448)
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
2026-08-06 17:45:03 +00:00
xarmian df741172fe fix(a11y): keep Escape consumption event-scoped so one press closes one layer (BUG-2441, TASK-2448)
`isBlockedByModal()` answers "is a viewer lease held RIGHT NOW". `Lightbox`'s
escape-stack handler calls `onClose()` synchronously, and Svelte flushes the
teardown — hence `lease.release()` — inside that call. Every `window` keydown
listener later in the SAME dispatch therefore asked against an empty stack, was
told "nothing is in front of you", and closed a second layer: one Escape closed
the viewer AND the `DockedSheet` / `BottomSheet` underneath it. The query was
right; the moment it was read was not.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-06 17:02:18 +00:00
xarmian f8ecb69b1b fix(editor): deliver server capabilities to the image NodeView (BUG-2426, 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
2026-08-06 16:50:10 +00:00
xarmian 03e0edb605 feat(attachments): complete the MIME matrix and address fence (TASK-2434)
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
2026-08-06 16:50:10 +00:00
xarmian 30fa9fc3dd refactor(attachments): route inline images through the unified viewer (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
2026-08-06 16:50:10 +00:00
xarmian 092498da23 feat(a11y): make inline body images keyboard-activatable (TASK-2432)
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
2026-08-06 16:50:10 +00:00
xarmian 7cfe50d842 feat(a11y): defer global key and gesture owners to a frontmost viewer (TASK-2430)
Global keyboard/gesture owners now consult the shared arbitration helper
(`isBlockedByModal`) instead of acting unconditionally, so the
native-`dialog:modal` branch is enforced everywhere rather than only in the
two route files TASK-2429 rewired.

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-06 16:50:10 +00:00
xarmian a3f272a97a feat(attachments): make the viewer open gate total across every surface (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.
2026-08-06 16:50:10 +00:00