Commit Graph

1247 Commits

Author SHA1 Message Date
xarmian e9af504cbc test(store): pin DR-15 attachment clone/move/bundle semantics (TASK-2478)
Three store-level pinning tests for PLAN-2392's DR-15 — no behavior changes,
locking down assumptions the copy / move / bundle paths rely on silently:

- Clone independence: a cross-workspace copy mints a fresh, live attachment row
  that shares only the bytes (content hash), and soft-deleting the clone or the
  source never cascades to the other (both directions pinned; there is no
  store-level attachment restore, so delete stands in for the row-separation
  invariant a restore would ride).
- A move charges both workspaces: ArchiveSource soft-deletes only the source
  ITEM; the source attachment rows (the original attached via item_id) stay live
  and their bytes are counted in both workspaces' storage usage. The clones land
  live on the copied item with the variant reparented to the new original.
- Bundle round-trip orphan: WorkspaceAttachmentsForExport includes a live
  attachment whose parent item is soft-deleted, while ExportWorkspace's item list
  excludes that item — the divergence that leaves import unable to remap ItemID
  (handlers_import_bundle.go:479-510), landing the row as an orphan. Pinned as
  known behavior, not fixed.

Dual-dialect (store helpers only; passes under make test-pg). Each pin was
mutation-verified to fail when its behavior regresses (content-hash delete
cascade, archive→attachment cascade by item_id, and an export that stops
excluding soft-deleted items), and reviewed to a fresh-angle CLEAN.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-08 20:03:11 +00:00
xarmian 0f4b695e2d Merge pull request #1065 from PerpetualSoftware/feat/attachment-surface-chrome
PLAN-2392 phase 3c-i: the attachment surface grows its chrome
2026-08-08 15:34:54 -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 4a4ff6a45d Merge pull request #1064 from PerpetualSoftware/feat/attachment-viewer-zoom
PLAN-2392 phase 3b: desktop zoom for the attachment viewer
2026-08-08 03:38:35 -04: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 eca03e663d Merge pull request #1061 from PerpetualSoftware/dependabot/npm_and_yarn/web/npm-minor-and-patch-0fbc33a3b3
chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 24 updates
2026-08-06 20:11:17 -04:00
xarmian 68907e4d92 Merge pull request #1062 from PerpetualSoftware/dependabot/go_modules/go-minor-and-patch-48971f1ce0
chore(deps)(deps): bump the go-minor-and-patch group across 1 directory with 13 updates
2026-08-06 19:55:47 -04: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
xarmian a62a50d672 fix(nix): refresh vendorHash for go-minor-and-patch group bump
Claude-Session: https://claude.ai/code/session_01RNcrc3CtXwJwreubtHTgN6
2026-08-06 23:37:39 +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
dependabot[bot] ca45539925 chore(deps)(deps): bump the go-minor-and-patch group across 1 directory with 13 updates
Bumps the go-minor-and-patch group with 10 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/JohannesKaufmann/html-to-markdown/v2](https://github.com/JohannesKaufmann/html-to-markdown) | `2.5.1` | `2.5.2` |
| [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) | `5.2.5` | `5.3.1` |
| [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) | `5.9.2` | `5.10.0` |
| [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) | `0.56.0` | `0.57.0` |
| [github.com/pb33f/libopenapi](https://github.com/pb33f/libopenapi) | `0.36.3` | `0.38.7` |
| [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) | `1.23.2` | `1.24.1` |
| [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) | `9.19.0` | `9.22.0` |
| [github.com/santhosh-tekuri/jsonschema/v6](https://github.com/santhosh-tekuri/jsonschema) | `6.0.2` | `6.0.3` |
| [golang.org/x/image](https://github.com/golang/image) | `0.43.0` | `0.44.0` |
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | `1.50.0` | `1.56.0` |



Updates `github.com/JohannesKaufmann/html-to-markdown/v2` from 2.5.1 to 2.5.2
- [Release notes](https://github.com/JohannesKaufmann/html-to-markdown/releases)
- [Commits](https://github.com/JohannesKaufmann/html-to-markdown/compare/v2.5.1...v2.5.2)

Updates `github.com/go-chi/chi/v5` from 5.2.5 to 5.3.1
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.2.5...v5.3.1)

Updates `github.com/jackc/pgx/v5` from 5.9.2 to 5.10.0
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.9.2...v5.10.0)

Updates `github.com/mark3labs/mcp-go` from 0.56.0 to 0.57.0
- [Release notes](https://github.com/mark3labs/mcp-go/releases)
- [Commits](https://github.com/mark3labs/mcp-go/compare/v0.56.0...v0.57.0)

Updates `github.com/pb33f/libopenapi` from 0.36.3 to 0.38.7
- [Release notes](https://github.com/pb33f/libopenapi/releases)
- [Commits](https://github.com/pb33f/libopenapi/compare/v0.36.3...v0.38.7)

Updates `github.com/prometheus/client_golang` from 1.23.2 to 1.24.1
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.1/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.1)

Updates `github.com/redis/go-redis/v9` from 9.19.0 to 9.22.0
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.19.0...v9.22.0)

Updates `github.com/santhosh-tekuri/jsonschema/v6` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/santhosh-tekuri/jsonschema/releases)
- [Commits](https://github.com/santhosh-tekuri/jsonschema/compare/v6.0.2...v6.0.3)

Updates `golang.org/x/crypto` from 0.53.0 to 0.54.0
- [Commits](https://github.com/golang/crypto/compare/v0.53.0...v0.54.0)

Updates `golang.org/x/image` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/image/compare/v0.43.0...v0.44.0)

Updates `golang.org/x/term` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/term/compare/v0.44.0...v0.45.0)

Updates `golang.org/x/text` from 0.39.0 to 0.40.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.39.0...v0.40.0)

Updates `modernc.org/sqlite` from 1.50.0 to 1.56.0
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.50.0...v1.56.0)

---
updated-dependencies:
- dependency-name: github.com/JohannesKaufmann/html-to-markdown/v2
  dependency-version: 2.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/mark3labs/mcp-go
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/pb33f/libopenapi
  dependency-version: 0.38.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/santhosh-tekuri/jsonschema/v6
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/crypto
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/image
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/term
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/text
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: modernc.org/sqlite
  dependency-version: 1.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 23:08:54 +00:00
xarmian 143d6dd4fc Merge pull request #1060 from PerpetualSoftware/feat/attachment-viewer-unification
PLAN-2392 phase 3a: viewer unification (parity commit + DR-4b a11y contract)
2026-08-06 16:33:26 -04:00
xarmian 5320eebe9b Merge pull request #927 from PerpetualSoftware/dependabot/github_actions/actions/cache-6.1.0
chore(ci)(deps): bump actions/cache from 5.0.5 to 6.1.0
2026-08-06 14:24:59 -04:00
xarmian 0cdd4620fa Merge pull request #982 from PerpetualSoftware/dependabot/github_actions/actions/setup-go-7.0.0
chore(ci)(deps): bump actions/setup-go from 6.5.0 to 7.0.0
2026-08-06 14:24:57 -04:00
xarmian 08732f2653 Merge pull request #983 from PerpetualSoftware/dependabot/github_actions/actions/setup-node-7.0.0
chore(ci)(deps): bump actions/setup-node from 6.4.0 to 7.0.0
2026-08-06 14:24:55 -04:00
xarmian 6be16e33db Merge pull request #1040 from PerpetualSoftware/dependabot/github_actions/actions/checkout-7.0.1
chore(ci)(deps): bump actions/checkout from 6.0.2 to 7.0.1
2026-08-06 14:24:52 -04:00
xarmian 10c7b9a8b0 Merge pull request #1039 from PerpetualSoftware/dependabot/github_actions/actions-minor-and-patch-dee7dd5463
chore(ci)(deps): bump docker/login-action from 4.4.0 to 4.6.0 in the actions-minor-and-patch group across 1 directory
2026-08-06 14:24:50 -04: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
dependabot[bot] 2ab5b1033f chore(ci)(deps): bump actions/setup-go from 6.5.0 to 7.0.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.5.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/924ae3a1cded613372ab5595356fb5720e22ba16...b7ad1dad31e06c5925ef5d2fc7ad053ef454303e)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 17:31:09 +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
xarmian 13852439e1 feat(a11y): give the attachment viewer a real modal contract (TASK-2429)
`Lightbox` was a `role="presentation"` fixed div with a local `<svelte:window>`
keydown handler and no focus management at all. 3a's later tasks delete the
editor's hand-rolled `showModal()` dialog and route inline body images here, so
everything that dialog was getting from the platform for free has to exist here
first (DR-4b).

The contract:

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

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

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

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

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

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

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

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

TASK-2429
2026-08-06 16:50:10 +00:00
xarmian 884ddc575e feat(attachments): add viewer open channel and per-host viewer host (TASK-2428)
Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-06 16:50:10 +00:00
xarmian 4fb946c528 feat(a11y): add refcounted viewer backdrop manager (TASK-2427)
New `web/src/lib/a11y/viewerBackdrop.ts` (PLAN-2392 phase 3a): a
refcounted, lease-stacked owner of `inert` on `document.body.children`
for body-portaled viewer surfaces, plus the modal-arbitration helpers.

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

Nothing consumes it yet — independently green.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-06 16:50:10 +00:00
xarmian 417929c5a4 Merge pull request #1037 from jairbj/feat/nix-flake-packaging
feat(nix): add flake packaging with CI build
2026-08-06 01:21:28 -04:00
xarmian 0d31bae61b fix(nix): refresh vendorHash for current main's go.sum
The hash was computed against July-27 main; go.sum has since moved
(dependabot + mainline work), and CI builds the PR merged with main.

Claude-Session: https://claude.ai/code/session_01RNcrc3CtXwJwreubtHTgN6
2026-08-06 05:02:29 +00:00
xarmian bda6987125 Merge pull request #1058 from danfinn5/fix/cloud-mode-error-messages
fix(cli): surface actionable errors for cloud-mode setup failures
2026-08-06 01:01:56 -04:00
xarmian 71c18200c5 ci(nix): pin nix-installer-action to the actual v22 commit
The pinned SHA was a genuine ancestor commit from the upstream repo but
~68 commits behind the v22 tag it claimed; the workflow's own convention
requires SHA and version comment to move together.

Claude-Session: https://claude.ai/code/session_01RNcrc3CtXwJwreubtHTgN6
2026-08-06 04:43:46 +00:00
xarmian c491a4dfcd Merge pull request #1059 from PerpetualSoftware/feat/attachment-options-panel
feat(attachments): an options panel for files, everywhere you meet them (PLAN-2392 phase 2)
2026-08-04 19:00:57 -04:00