From a3f272a97a885c4c06ef67da1cd750b2b2228995 Mon Sep 17 00:00:00 2001 From: xarmian Date: Wed, 5 Aug 2026 14:39:36 +0000 Subject: [PATCH] feat(attachments): make the viewer open gate total across every surface (TASK-2431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 `` 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. --- web/src/lib/attachments/events.ts | 16 +- .../attachments/fixtures/LightboxStub.svelte | 3 +- .../attachments/fixtures/lightboxStub.ts | 9 +- .../viewerImagePayload.svelte.test.ts | 403 +++++++++++++++ web/src/lib/components/common/Lightbox.svelte | 113 +++- .../components/common/Lightbox.svelte.test.ts | 278 ++++++++-- .../items/ItemAttachmentStrip.svelte | 91 +++- .../items/ItemAttachmentStrip.svelte.test.ts | 27 + .../components/timeline/ItemTimeline.svelte | 216 +++++++- .../timeline/ItemTimeline.svelte.test.ts | 482 ++++++++++++++++++ .../fixtures/InertCommentEditor.svelte | 17 + 11 files changed, 1565 insertions(+), 90 deletions(-) create mode 100644 web/src/lib/components/attachments/viewerImagePayload.svelte.test.ts create mode 100644 web/src/lib/components/timeline/ItemTimeline.svelte.test.ts create mode 100644 web/src/lib/components/timeline/fixtures/InertCommentEditor.svelte diff --git a/web/src/lib/attachments/events.ts b/web/src/lib/attachments/events.ts index ea76ebbc..8d520cc0 100644 --- a/web/src/lib/attachments/events.ts +++ b/web/src/lib/attachments/events.ts @@ -281,9 +281,19 @@ export interface LightboxImage { * Metadata the viewer may caption with, all NULLABLE for the same reason * the panel's three are: an emitter knows only what its own surface gives * it, and an inline image's HEAD probe may not have completed or may have - * failed. Structurally a superset of the `Lightbox` component's own - * `LightboxImage` ({id, alt}), so a set built for this channel is passed - * straight through to it. + * failed, while an upload event carries only four fields + * (`UploadedAttachment`). + * + * `mime_type` is not decoration: it is what lets a CONSUMER re-state the + * DR-16 open gate over a whole set rather than trusting the one element + * that was clicked (TASK-2431). `width` / `height` are here ahead of any + * reader — phase 3b's pixel-based loading policy needs them, and adding + * them now costs one nullable field per producer instead of reopening the + * event, the host and every producer later. + * + * This is the ONLY declaration of the shape. `Lightbox.svelte` used to + * carry its own `{id, alt}` twin; it now re-exports this one, so the + * component's props and the channel's payload cannot drift. */ filename: string | null; mime_type: string | null; diff --git a/web/src/lib/components/attachments/fixtures/LightboxStub.svelte b/web/src/lib/components/attachments/fixtures/LightboxStub.svelte index 3af4eccc..b89c9d2c 100644 --- a/web/src/lib/components/attachments/fixtures/LightboxStub.svelte +++ b/web/src/lib/components/attachments/fixtures/LightboxStub.svelte @@ -7,9 +7,10 @@ + +