Commit Graph

701 Commits

Author SHA1 Message Date
xarmian 00a91dfcf4 feat(push): session targeting — target_session_id + delivered_sessions (TASK-2588) (#1108)
* watchevents: add session-targeted push delivery predicate

PLAN-2558 S5 (TASK-2588). Notification gains TargetSessionID,
evaluated in the existing per-connection KindPush predicate in
watchNotificationVisible alongside TargetUserID — one delivery path,
targeted is broadcast-with-a-predicate, no bus changes. Empty
TargetSessionID (the pre-S5 shape) still matches every one of the
target user's sessions.

* server: accept target_session_id on push, report delivered_sessions

PLAN-2558 S5 (TASK-2588). POST .../items/{slug}/push accepts an
optional target_session_id (an id from GET /api/v1/sessions) and the
response gains delivered_sessions — a prediction read from the S1
presence registry at push time, scoped to the caller's own
ListForUser(userID) so a vanished id and one belonging to a different
user are both an honest 200/0 with no existence oracle across users.
Omitting the field keeps the exact pre-S5 request/response shape.

* web: session picker in the push composer, targeted-miss handling

PLAN-2558 S5 (TASK-2588). PushToAgentDialog gains a target picker
(broadcast default + one option per live session), reusing the
presence read already fetched for the count — no second GET
/api/v1/sessions. Selecting a session passes target_session_id;
leaving it untouched keeps the exact pre-S5 3-argument push() call.
A targeted miss (delivered_sessions === 0) toasts "that session is
gone — refresh the list", drops the selection back to broadcast, and
re-polls presence instead of closing — zero delivery means nothing
was sent, so nothing is duplicated by resending.

* server: bound target_session_id, skip publish on a targeted miss

Codex round 1 fixes for TASK-2588:

- Cap target_session_id at 256 runes (400 over-cap) so an authenticated
  caller can't park arbitrary garbage in the bus's shared replay buffer;
  a registry-issued id (36 runes) can never hit this bound.
- Snapshot presence BEFORE publish instead of counting after: the old
  order raced a target disconnecting between publish and count, which
  could report delivered_sessions=0 on a push that had already landed
  once. A targeted push now skips the publish entirely when its id
  isn't in the pre-publish snapshot — session ids are per-connection
  and never reused, so a target absent now can never be matched later,
  making the 0 a guarantee rather than a race. Broadcast is unaffected
  (still publish-always, pre-publish count).

Strengthened the targeted-miss and cross-user tests to assert the bus
does not grow (not just that the notification fails to arrive
downstream) — verified this fails if the skip-on-miss guard is
reverted.

* push targeting: document the pushed ruling, fix stale picker selection, guard mixed-version responses

Codex round 2 dispositions for TASK-2588:

- pushed:true on a skipped publish is RULED, not a bug (dispatcher):
  moved the ruling from a test comment onto the contract itself —
  pushResponse.Pushed's own doc comment in Go, mirrored in the TS
  ItemPushResult doc comment.
- Fixed a real sharp edge: when a presence refresh drops the selected
  session, a <select> can visually fall back to "All connected
  sessions" while the bound value stays the stale id, so the wire
  would carry a dead target the UI no longer shows as selected.
  Added reconcileSelectedSession(), called at every point `sessions`
  is reassigned outside the fresh-open reset (a live poll, a failed
  read, and the staleness-expiry path).
- Guarded the mixed-version hazard with a cheap check, not capability
  negotiation (the deployment shape — web assets embedded in the
  server binary — bounds this to a transient stale tab, argument
  recorded in the comment): delivered_sessions is now optional on the
  wire type, and a targeted send whose response omits it entirely is
  treated as UNKNOWN (info toast, dismiss like a normal success) —
  never inferred as a confirmed miss.

Verified all three new/changed legs actually catch their regression
by temporarily reverting each fix and confirming the corresponding
test fails, then restoring.

* push targeting: fix stale publish-guarantee comments (codex round 3)

Two doc-comment remnants of round 2's skip-on-miss fix, both claiming
push unconditionally publishes:

- watchevents.KindPush's doc comment ("publishes exactly one of
  these") now notes handlePushToItem decides whether to publish at
  all, and points at TargetSessionID / pushResponse.DeliveredSessions
  for why.
- api.items.push()'s JSDoc in client.ts no longer claims a resolved
  promise means "published to the bus" unconditionally — a targeted
  miss resolves with delivered_sessions: 0 and nothing published.

Comment-only; no behavior change.
2026-08-15 14:52:25 -04:00
dependabot[bot] 312f28dd14 chore(deps)(deps-dev): bump vitest from 3.2.6 to 4.1.10 in /web (#1045)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.6 to 4.1.10.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 12:34:23 -04:00
xarmian b887b0bfe1 test(web): capture pristine DOM probes at module load in mockOpenModals helpers (#1105)
vitest 4 hands back the SAME spy when vi.spyOn targets an already-spied
method, so a helper that re-captures "the real function" mid-test captures
the spy itself and the pass-through branch recurses (swallowed by the
:modal probe's guard, which then reads as ':modal unsupported'). Capturing
document.querySelectorAll / Element.prototype.matches once at module load
is correct under both vitest 3 and 4; suite measured 1609/1609 on each.

Unblocks the vitest 3->4 major (dependabot #1045), whose merged-tree run
failed 3 Lightbox drag-abort tests (TASK-2458) through this pattern.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-15 12:12:25 -04:00
dependabot[bot] a7b70c2092 chore(deps)(deps-dev): bump @testing-library/jest-dom in /web (#1044)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.9.1 to 7.0.1.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.1)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 11:46:01 -04:00
dependabot[bot] 8ba08c7164 chore(deps)(deps): bump the npm-minor-and-patch group (#1072)
Bumps the npm-minor-and-patch group in /web with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@dagrejs/dagre](https://github.com/dagrejs/dagre) | `3.0.0` | `3.1.0` |
| [mermaid](https://github.com/mermaid-js/mermaid) | `11.16.0` | `11.16.1` |
| [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) | `0.9.77` | `0.9.78` |
| [yjs](https://github.com/yjs/yjs) | `13.6.31` | `13.6.32` |
| [marked](https://github.com/markedjs/marked) | `18.0.7` | `18.0.9` |
| [svelte-check](https://github.com/sveltejs/language-tools) | `4.7.4` | `4.7.5` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.0` | `8.2.1` |


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 `mermaid` from 11.16.0 to 11.16.1
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.16.0...mermaid@11.16.1)

Updates `svelte-dnd-action` from 0.9.77 to 0.9.78
- [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.31 to 13.6.32
- [Release notes](https://github.com/yjs/yjs/releases)
- [Commits](https://github.com/yjs/yjs/compare/v13.6.31...v13.6.32)

Updates `marked` from 18.0.7 to 18.0.9
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.7...v18.0.9)

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

Updates `vite` from 8.2.0 to 8.2.1
- [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/v8.2.1/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: mermaid
  dependency-version: 11.16.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-dnd-action
  dependency-version: 0.9.78
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: yjs
  dependency-version: 13.6.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-check
  dependency-version: 4.7.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 11:24:28 -04:00
xarmian bfa90826e9 feat(web): quick actions push to a connected agent session (TASK-2562) (#1103)
* feat(web): quick actions push to a connected agent session (TASK-2562)

PLAN-2558 S4. `resolvePrompt()` already did the templating push needed
(`{ref} {title} {status} {priority} {collection} {content} {fields} {plan}
{phase}`); only the last hop was a clipboard ferry. That hop is now a push,
and the clipboard becomes the fallback rather than the mechanism.

Zero-touch migration, as the task required: quick actions are still
`{label, prompt, scope, icon}` in collection settings. No settings rewrite,
no schema change, no per-action opt-in.

Routing, per the plan's ruling — with zero live sessions, fall back to the
clipboard with an honest toast, never a hard error and never a queue:

  session(s) live   push the collapsed prompt; toast hedges ("delivery isn't
                    confirmed") because a push gets no ack
  zero sessions     copy, "No agent session connected — copied to clipboard
                    instead"
  can't tell        copy. This is where S4 DIVERGES from S3's dialog, which
                    leaves Send enabled on an unreadable presence answer. The
                    dialog is right to: the warning is on screen and the user
                    chooses with it in front of them. A quick action asks
                    nobody, so the tie goes to the lossless branch — copying
                    when we could have pushed costs one paste, pushing into
                    nothing loses the instruction outright.
  no item to        collection-scope actions keep the pre-S4 behavior exactly.
  address           The endpoint is POST .../items/{slug}/push and there is
                    nothing to point it at, so they don't even spend a
                    presence read finding that out.

PRESENCE IS READ WHEN THE MENU OPENS, NOT ON THE CLICK, and that is the one
non-obvious thing in this diff. Both clipboard APIs want the user gesture
that is live during the click handler and gone after a network round-trip
(Safari strictest, Firefox too). Deciding from a read issued on the click
would put an await in front of the very fallback this slice promises. The
cost is a small window — click before the first read lands and presence is
null, which routes to the clipboard with an honest toast — and that is the
right way round.

The menu's footer line now says which way the next click will go, so the
routing is visible before it happens rather than only in the toast after.
A logged-out or workspace-token viewer gets a 401 from /sessions, which is
"can't tell", which is the clipboard — today's behavior, no gating needed.

Push failure splits on the same line CopyItemDialog and the S3 composer draw
(DR-13): a recognised pre-publish refusal means nothing went out, so the copy
is OFFERED as a toast action (a fresh gesture, which is what makes a
clipboard write work this long after the original). An unrecognised failure
leaves the outcome unknown — the handler publishes BEFORE it writes its
response — so nothing is offered, because a paste would be the duplicate the
message is warning about on an endpoint with no idempotency key.
PRE_PUBLISH_ERROR_CODES moved out of PushToAgentDialog into
$lib/push/dispatch so the two surfaces can't drift on it.

Also: the local `copyToClipboard` is replaced by `$lib/utils/clipboard`'s.
The local one returned true from the promise path WITHOUT awaiting it, so a
rejected write reported success and never reached the execCommand fallback —
harmless when copying was a convenience, not harmless now that "we copied
instead" is a load-bearing claim.

Verified live against a throwaway instance (built binary, real browser),
three legs, with the SSE stream as the receipt:

  no session     tagline "No agent session connected — actions copy to your
                 clipboard"; toast matches the ruling; clipboard holds
                 "Implement TASK-9: Ship the thing (status open)"
  one session    tagline "Pushes to your connected agent session"; the
                 connected stream RECEIVED {"kind":"push","item_ref":
                 "TASK-9","summary":"Implement TASK-9: ..."}; clipboard
                 untouched
  presence 503   same live session still connected, /sessions aborted: copies
                 instead, and the stream's push count did NOT increase — the
                 counterfactual, not just the end state

Each of the five behaviours is mutation-tested 1:1 against its test: an
await before the copy fails ONLY the synchronous-gesture test; routing
'unknown' to push fails only the two uncertainty tests; dropping the collapse
fails only the raw-vs-collapsed test; offering a copy on an unconfirmed push
fails only that test; copying on the happy path fails three.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): expire a stale presence answer in the quick-actions menu (codex round 1)

Codex's one finding, and it is real. A FAILED poll already degrades to
'unknown'; a poll that HANGS does not — it simply never writes, so the last
count stayed in place indefinitely while the menu went on offering a push
into a session that may have dropped minutes earlier. That is the one
direction that loses the user's instruction, which is the whole thing this
slice exists to prevent.

A 'known' answer now expires after 30s without a refresh — the server's own
worst-case presence staleness (watchEventsKeepaliveInterval), the same bound
and the same reasoning as PushToAgentDialog's round-2 fix. Requests already
in flight at the moment of expiry are retired (presenceAppliedSeq advances to
presenceSeq) so one issued BEFORE the expiry cannot land after it and restore
the very count we just declared too old to trust.

The expiry is checked in TWO places, and the second is the one worth noting:
the poll tick rewrites the state (so the footer line stops claiming a
connection), but the ROUTING decision reads through `currentPresence()` at
click time. A tick-only expiry leaves a window of up to one whole poll
interval in which the menu still pushes against a count it has already
outlived — and a click is exactly what lands in that window.

Both halves mutation-tested: disabling the expiry fails the two staleness
tests and leaves the control leg (polls still landing → no downgrade) green;
reading raw `presence` at click time instead of `currentPresence()` fails
ONLY the between-ticks test.

npm run check 0 errors · web unit suite 1607 passed.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): the offered copy reports its own outcome (codex round 2)

Round 2's one finding, and it is real. `Copy instead` on a push-failure toast
discarded the `copyToClipboard()` result, and taking the offer dismisses the
toast that carried it — so a failed copy said nothing at all. That silence is
worst on exactly this path: a pre-publish refusal means the instruction was
never sent, so a silently-failed copy leaves it neither sent NOR copied, with
the user believing they rescued it.

The offer now routes through the same `copyAndAnnounce` every other clipboard
path uses, under a new `'offered'` ClipboardReason that renders the plain
"Copied to clipboard" — the user asked for the copy, so there is no absent
push to explain — and the ordinary error on failure.

Mutation-tested: reverting to the discarded-result form fails the new test
and the existing pre-publish test, and nothing else.

npm run check 0 errors · web unit suite 1608 passed.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): flip the quick-actions footer line when the answer comes due (codex round 3)

Round 3's finding, and it is a self-inconsistency round 1 introduced. The
click-time expiry check made the ROUTING correct immediately, but the footer
line still read raw `presence` until the next 10s poll tick — so for up to a
full interval the menu said "Pushes to your connected agent session" while the
next click would copy. That line's entire job is to say what the next click
will do.

A successful read now arms a one-shot timeout at the exact expiry, so the
display flips when the answer comes due rather than when a poll happens to
notice.

`currentPresence()` STAYS, and the redundancy is the point: a timer is a
request, not a guarantee. Browsers throttle timers hard in a backgrounded tab,
so the expiry can fire long after it came due — including after the user has
returned and clicked. The timer keeps the DISPLAY honest; the click-time check
keeps the DECISION correct, and only the decision can lose a message. The poll
tick keeps its expiry check for the same reason.

Both halves mutation-tested, and they fail different tests: dropping
`armExpiry()` fails ONLY the comes-due test; dropping `currentPresence()` at
the click fails ONLY the throttled-timer test. That second test models
throttling by moving the CLOCK without running any timer — which is exactly
what a throttled tab looks like from the component's side, and is not
reachable with advanceTimersByTime.

npm run check 0 errors · web unit suite 1609 passed.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 10:03:42 -04:00
xarmian 2c5803a204 fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540) (#1101)
* fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540)

A page reads its items and only then subscribes to SSE. A mutation landing in
that window reaches nobody: no subscription exists yet so the frame is never
received, and nothing reconciles the gap afterwards — the row stays stale until
some unrelated event happens to trigger a sync.

`pendingSyncOnConnect` is exactly the right mechanism and already existed, but
was armed only on leader promotion and the lock-failure fallback. The FIRST
connect — the one every page load performs — was uncovered. Arms it on every
EventSource open instead.

Cursor-advance semantics are untouched: the delta is asked from whatever cursor
syncService already holds. IDEA-2535 owns that question.

Also removes the "first leader vs promoted follower" classification (a
`navigator.locks.query` probe plus a >100ms grant-delay heuristic from
TASK-1359 rounds 2-3). It existed solely to gate this flag; with the flag armed
unconditionally nothing reads it, and keeping it would mean a `locks.query()`
round-trip per connect producing a value no one consumes. Strictly more
coverage, not a trade — every case it classified as "promoted" still arms.

VERIFICATION — and three instruments that did NOT discriminate before one did:

- Unit tests (5) pin the mechanism: armed on the lock path, on the
  no-leader-election fallback, dispatched AFTER open rather than before (the
  TASK-1359 round-4 ordering property this must not lose), claimed once across
  the onopen/`connected` arms, and re-armed for the next connect. Reverting the
  fix reddens all 5.

- Collection page + "is the row visible": CANNOT discriminate. That page runs
  its own deltaSync on mount, which covers the same window either way. Fixed
  and unfixed both "passed".

- Graph page + "is the node's title in the page text": BLIND. A positive
  control — item created with no race at all — is also "not present", so every
  reading was measuring nothing.

- Graph page + /graph response bodies, natural timing: still cannot
  discriminate. The write consistently lands before the page's own first read,
  so nothing is ever missed and both builds "recover".

- What finally worked: builds with the EventSource open delayed 3s so the
  window is wide enough to aim at, write timed into it. 3/3 LOST on unfixed,
  3/3 RECOVERED on fixed, both binaries confirmed serving and differing only in
  the arming sites.

One hypothesis was refuted along the way rather than written down as fact: I
suspected the graph subscribed too late to receive the connect dispatch. An
instrumented run showed `dispatchSyncRequired subscribers=2` — both syncService
and the graph are registered before it fires. The real reason those runs failed
was that a stale server process was still bound to the port, so they were
served by an unrelated binary.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): fence the connect-sync against a stale EventSource (codex review)

`pendingSyncOnConnect` is shared state and the open/connected handlers closed
over no identity, so on a fast workspace switch source A's already-queued
handler could still run after B existed — clearing the flag and broadcasting on
B's channel, at which point B's own open would find it false and SKIP the sync
it needed. `close()` does not retract queued event tasks, so this is reachable.
It would silently reopen the exact gap this branch closes, on the switch path
where a fresh read is most likely to be stale.

Each handler now returns unless its own source is the current one. Those guards
also stop a torn-down source writing `status` / broadcasting, which they never
did before. Listener registration uses the local `source` throughout rather
than re-reading module state (behaviour-identical; the two are the same object
at registration).

`disconnect()` also clears the flag. Labelled in-code as belt-and-braces
rather than implied load-bearing: mutation-testing shows removing that line
ALONE changes nothing observable, while removing the identity guards reddens
the stale-source test. Kept because leaving per-connection state set after the
connection is gone is how this bug arose.

Three tests added, and the mutation testing is worth recording because the
first attempt was a false green: dropping the identity check inside
`claimPendingSync` reddened NOTHING, since the handler-level guards still
caught it — a layered-guard mask. Only removing every guard isolates which
layer acts. The tests now discriminate at that granularity.

Also covers the lock-failure fallback arming path, which had no test at all.

Codex's other finding — follower tabs have the same uncovered window and cannot
use this mechanism, since they never open an EventSource — is real and filed as
BUG-2576, along with the adjacent unguarded listeners this commit had no reason
to touch.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 08:41:00 -04:00
xarmian e03ba45b5c feat(web): push-to-agent composer in the item view (TASK-2561) (#1099)
* feat(web): push-to-agent composer in the item view (TASK-2561)

PLAN-2558 S3 — the web half of IDEA-2544's push-to-harness. Adds
`api.items.push`, a new `api.sessions.list`, and a "Push to agent…" row
in the item pane's ⋯ menu that opens a small composer.

The deliverable is the presence line, not the textarea. `pad push` is
fire-and-forget — no durable inbox, no ack, no "nobody was listening"
warning — which is defensible for a CLI verb typed by someone who knows
their own session is running, and indefensible for a button. So the
dialog answers "is anything listening?" before the click, and keeps
three states apart rather than two:

  N > 0        send, worded "N session connected", never "will be
               delivered" — the registry can name a session that died up
               to ~30s ago and no push gets a receipt
  N == 0       send DISABLED. Nothing listening means the message is
               lost, not queued; the empty state offers the clipboard
               instead (the fallback S4 rules for quick actions)
  can't tell   send ENABLED, uncertainty stated. A 503/401/network
               failure is not zero — rendering it as zero is the exact
               lie handleListSessions returns 503 rather than an empty
               list to avoid

The menu row is gated on a resolved user, not on canEdit: push is
self-addressed, so a viewer pushing an item into their own session is a
read. Without a user the endpoint 401s.

$lib/push/message mirrors the server's rune-after-collapse accounting so
an over-length message is caught in the composer instead of coming back
as a 400. It deliberately does not use JS `\s`: Go's unicode.IsSpace and
`\s` disagree in both directions (U+0085 is whitespace to Go only,
U+FEFF to JS only), so a `\s` client under-counts a pasted BOM and
over-counts a pasted NEL. The agreement is pinned by a shared fixture
(internal/server/testdata/push_message_cases.json) read by BOTH
internal/server/push_message_collapse_test.go and the web unit test — a
TS-only table would assert a belief about Go rather than Go's behaviour.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): close the push composer's races and ambiguity gaps (codex review)

Round-1 review findings on the S3 composer, all real:

- ItemDetail did not reset `pushDialogOpen` on an item switch. The dialog
  is {#key itemSlug}-remounted while `open` is owned by the parent, so a
  stale `true` silently REOPENED the composer pointed at the new item.
  The reset block's existing comment (written for copyDialogOpen)
  describes this exact failure. Verified live, with the counterfactual:
  reverting the one-line fix reopens the dialog on item B after a
  client-side navigation. (The typed draft does NOT carry over — the
  {#key} remount clears it — so the defect is the silent reopen, not a
  retargeted message.)

- Presence polls shared one generation counter, which fences OPENINGS,
  not requests. A stalled poll could resolve after a later one and
  overwrite a fresh count with a stale one, re-arming Push against a
  session list already known to be empty. Added a per-request sequence;
  only a strictly newer response is applied.

- Nothing bounded a `/sessions` read, and 'checking' disables Push, so a
  request that never settled stranded the composer with a dead button and
  no explanation. It now degrades to the honest "can't tell" state after
  5s; a later response still lands and upgrades the answer.

- A failed send re-armed Push unconditionally. The handler publishes
  BEFORE writing its response, so an unstructured failure (rejected
  fetch, non-JSON 502) leaves the outcome genuinely unknown and a second
  click can deliver the instruction twice on an endpoint with no
  idempotency key. Split on the same line CopyItemDialog draws (DR-13):
  a structured PadApiError means the server refused before publishing —
  re-arm; anything else latches an outcome-unknown state.

- `willCollapse` compared against `String.trim()`, reintroducing the very
  JS-vs-Go whitespace mismatch $lib/push/message exists to avoid (JS
  trims a leading U+FEFF the server keeps; it leaves a U+0085 the server
  strips). Added `trimPushMessage`, which trims with Go's class.

- The textarea described only the counter, so the collapse note and the
  over-length error reached no screen reader. Both now live in one stable
  referenced node that swaps text rather than mounting and unmounting —
  an aria-describedby pointing at an absent id resolves to nothing.

- Positive presence wording implied the count was current. It now says
  "as of the last check" and names the ~30s window.

Test changes: the Go fixture test duplicated `strings.Fields` rather than
invoking the handler, so a change to the handler's normalization would
have left BOTH suites green — demonstrated by mutating the join
separator, which the copied-expression test did not notice and the new
handler-driven test caught on 22 cases. The bound is likewise now
asserted through the endpoint at 4096/4097 instead of comparing the
constant to a copy of itself.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): fence the push composer against destroyed instances, unrecognised errors, and a frozen count (codex round 2)

Three findings, one of them introduced by round 1's own fix:

- The send/copy continuation fence used the generation counter, which
  cannot see a keyed REMOUNT. `{#key itemSlug}` gives item B a fresh
  instance with its own counter, so item A's in-flight send still saw its
  own `gen` unchanged and called the SHARED parent `onclose` — closing the
  composer the user had just opened for B. Added a per-instance
  `destroyed` flag, which is what actually distinguishes "still mine to
  close" from "I no longer exist".

- The outcome-unknown split treated any PadApiError as proof the server
  refused before publishing. It isn't: the API client turns EVERY JSON
  error envelope into one, including a gateway 5xx invented after the
  handler published. Replaced with a whitelist of codes the handler and
  its middleware actually emit pre-publish; everything unrecognised is
  now ambiguous. The asymmetry is deliberate — an unnecessary "we can't
  tell" costs the user a check, a wrong re-arm delivers twice.

- PRESENCE_STALL_MS only rescued the FIRST read. A later poll that hung
  froze the count at its last value indefinitely while the UI kept
  rendering "1 session connected" as fact. A known answer now expires to
  "can't tell" after 30s without a refresh — the server's own presence
  staleness bound, so past it our answer carries no more authority.

Also dropped the status→alert role swap on the composer's live region:
changing a live region's role and its text together is not reliably
honoured, so the escalation was a promise the markup couldn't keep. The
blocking condition rides `aria-invalid` on the textarea instead.

The "latest ARRIVED, not latest ISSUED" behaviour of the sequence fence
is kept and now documented as a choice: dropping an early-arriving
response because a newer request exists strands the UI when that newer
request is the one that never settles.

Each fix mutation-tested 1:1 against its new test.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd

* fix(web): complete the pre-publish whitelist and retire in-flight polls on expiry (codex round 3)

Two of round 3's three findings were real:

- `csrf_error` and `email_not_verified` are middleware refusals, written
  strictly before the handler runs, so they belong in
  PRE_PUBLISH_ERROR_CODES. Without them a CSRF mismatch told the user we
  couldn't tell whether their message was sent, when nothing had been.

- The 30s staleness expiry didn't fence requests already in flight. A
  poll issued before the expiry could land after it and reinstate the
  very count we had just declared too old to trust. Expiry now advances
  `presenceAppliedSeq` to the current `presenceSeq`, retiring those
  responses; the poll issued in the same tick carries a newer seq and
  still applies.

The third finding — that `archived` belongs in the whitelist, and that
the launcher should be hidden for archived items because "the endpoint
always rejects them" — is REFUTED. handlePushToItem has no archived gate
(`requireItemVisible` admits archived items), and pushing to an archived
item against a running server returns 200 with `pushed: true`. There is
no `archived` error code on this path to whitelist, and hiding the
launcher would remove a capability that works. Recorded rather than
silently skipped so the next reader doesn't re-derive it.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 08:22:09 -04:00
xarmian e91c4fc261 fix(store): include the cursor's own second in /changes deltas (BUG-2539) (#1086)
* fix(store): include the cursor's own second in /changes deltas (BUG-2539)

items.updated_at / items.deleted_at are RFC3339 whole-second strings
(store.now()), while the /changes cursor is a unix-millisecond value —
normally the previous response's server_time. ItemsModifiedSince
formatted that cursor with the same second precision, truncating it
DOWN, then compared with a strict `>`. Every change landing in the
cursor's own second compared equal and was dropped, permanently: the
caller advances its cursor past that second and nothing reaches back.

User-visible symptom: a bulk archive ~450ms after a page seeded its
cursor left the item rendering as LIVE indefinitely — no banner, no
redirect — while the server had deleted_at set. It was never
archive-specific (updates were dropped identically); a missed update is
usually re-delivered by the next event, a missed deletion never is.

Compare inclusively against the truncated second instead. The boundary
second may be re-delivered, which every consumer of this endpoint
applies idempotently, and it is bounded to one second of changes per
sync. Sub-second storage is the other fix and is a migration, not a
one-liner: these comparisons are lexicographic on TEXT columns and
mixing precisions inverts them ("…20.451Z" sorts BEFORE "…20Z").

Verified against a live instance with four cursors all strictly earlier
than the archive in real time: two inside its second MISS, two in
earlier seconds HIT.

Tests:
- TestItemsModifiedSince_SameSecondCursor — same-second leg plus a
  previous-second control. Fails 3/3 unfixed, passes 3/3 fixed; the
  control passes on both.
- e2e bug-2539-sync-window — the banner must appear in the already-open
  page AND follow a /changes delta that carried the deletion, so a
  reload cannot satisfy it. The 450ms leg fails unfixed; 1200ms control
  passes on both.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* test(store,e2e): close the review gaps in the BUG-2539 counterfactuals

Codex review of 7905ed06 found no P1s and three P2s, all on whether the
tests actually measure the fix. Each was right.

Store test:
- It derived "same second" from a wall-clock reading taken BEFORE the
  writes, so a leg whose writes drifted into the next second would pass
  under the unfixed query and still be counted as evidence. It now reads
  the timestamps the writes actually STORED and compares those against
  the cursor's second.
- Misalignment retried instead of skipped, so the leg cannot silently
  stop testing anything.
- It never asserted the archived row comes back in `updated`, leaving
  the `(deleted_at IS NULL OR deleted_at >= ?)` arm free to regress to
  `>` unnoticed. Now asserted.

E2E:
- It accepted ANY /changes response carrying the deletion, including the
  cursor-seeding request setWorkspace fires during load, and never
  established that the page had loaded a live row. Both holes let it
  pass without exercising the incremental path. It now requires the
  page's own item GET to have seen deleted_at null AND the deletion to
  arrive on a /changes that resolved after the archive POST completed.
- Assert check.ok() before reading deleted_at; delete the scratch
  workspace at the end.

Also softened the comment's claim about re-delivery: `>=` makes the
endpoint at-least-once at the boundary and rows can repeat across
several rapid syncs, not just one. What makes that safe is that the
payload is server state rather than an increment, so the comment now
says that instead of "bounded to one second".

Counterfactuals re-run against a genuinely reverted query (the earlier
stash-based attempt was a no-op once the fix was committed, and passed
for that reason): store test fails 3/3 with all three assertions firing,
e2e 450ms leg fails, both controls pass.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* test(e2e): decide BUG-2539's oracle by request order, not response timing

Codex's second pass kept one P2 on the e2e, and it was right on both
halves. The oracle compared response arrival against the moment the
archive POST completed, which is racy in both directions: the server
publishes the SSE event BEFORE the bulk handler finishes writing its
response, so the incremental /changes can resolve first and be scored as
"not after the archive" (false failure); and a slow cursor-seeding
response can resolve after it and be scored as incremental (false pass).
The live-row check was existential — any live read of the row counted,
including one issued after the archive.

Both are now decided on the REQUEST side, where ordering is not racy:

- `/changes` requests are numbered as they are issued. The seed that
  setWorkspace fires on mount is number 0; only a LATER one carrying the
  deletion satisfies the assertion.
- Item GETs record their issue time, and `archiveSentAt` is stamped
  immediately BEFORE the POST goes out, so "the page read a live row
  before the archive" is decidable without waiting on anything.
- The async response handlers are collected and awaited before the
  assertions read their flags, instead of racing them.

Cleanup moved into a finally so a failing assertion no longer leaks the
scratch workspace, and its response is checked.

Counterfactual re-run at repeat-each=3 against a genuinely reverted
query: the 450ms leg fails 3/3, the 1200ms control passes 3/3, and both
legs pass 3/3 with the fix in.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* test(e2e): anchor BUG-2539's legs to preconditions, not a fixed delay

The fixed 450ms/1200ms offsets measured from navigation start were a
proxy for the real condition, and a machine-speed-dependent one. Under
parallel workers a slow load put the page's own item read AFTER the
archive, so the page rendered an already-archived row and the sync path
was never exercised — the live-row assertion then failed, correctly
reporting that the leg had not reproduced the scenario.

The legs now wait for the two things that actually have to be true —
the page has READ a live row (server-attested deleted_at null) and its
EventSource is subscribed (the /events response headers have arrived;
the handler subscribes before writing them) — and are named after the
mechanism: archive inside the cursor's own second vs after crossing the
next second boundary.

Also from Codex's third pass:
- the /changes URL match is anchored so it cannot also match
  /items-changes, whose requests would otherwise consume ordinals and
  let the cursor seed pass as the incremental sync;
- the incremental check now requires ordinal > 0 AND issue time at/after
  the archive, so a retried seed cannot pass on ordinal alone;
- the live-row check no longer compares clocks at all — a response
  carrying deleted_at null cannot come back after the archive applied,
  so the server attests it;
- the response-handler drain loops until no new handler was queued while
  awaiting, instead of snapshotting the array once;
- cleanup is recorded in `finally` and asserted after it, so a cleanup
  failure cannot replace the real one, and setup now runs inside the try
  so a failed setup cannot leak the scratch workspace.

Counterfactual against a genuinely reverted query: the same-second leg
now fails 6/6 (the delay-based version managed 7/8), control passes 6/6.
130 runs green with the fix in.

Both assertions carry the recorded state in their message: this leg
flaked twice in ~70 runs of an earlier revision and the artifacts were
cleared by the next run before they could be read, so a recurrence has
to explain itself from the failure text.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* test(e2e): make BUG-2539's leg PROVE it hit the second it is named after

Codex's fourth pass: the same-second leg never checked that it actually
landed in the cursor's second. It archived immediately after the
preconditions and assumed. On a slow machine that drifts across the
boundary, at which point the reproducing leg quietly becomes a second
control and passes against the very query it exists to convict — the
same "a test that silently stops testing anything" shape the store test
was already hardened against, which is what makes the point land.

Each attempt now compares two SERVER values: the `server_time` of the
page's first /changes (the seed — that value IS the client's
lastSyncTime, the cursor the failing sync used) against the `deleted_at`
the server stored. Same-second leg requires equality, boundary leg
requires difference, and a misaligned attempt is retried on a fresh
workspace (up to 6) instead of asserted on. Exhausting the attempts
fails with both seconds in the message.

Also from that pass:
- cleanup swallows a rejected delete rather than replacing the real
  failure with a transport error;
- `createdSlug` is set from the requested slug BEFORE parsing the
  response, so a malformed success cannot leak a workspace;
- request/response listeners are removed per attempt, so retries do not
  stack handlers;
- the live-row comment now says "carries no deleted_at" — a live item
  omits the field (omitempty) rather than sending null. The previous
  commit message said `deleted_at: null`; the check was always a falsy
  one, so only the wording was wrong.

Counterfactual against a genuinely reverted query: same-second leg fails
4/4, control passes 4/4. 12/12 green with the fix.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag

* test(e2e): scope BUG-2539's matchers per attempt; don't accept an unknown seed

Codex's fifth pass, both findings real:

- Retry ordinals were not scoped to the attempt's workspace. A retry
  reuses the page, which is still showing the previous attempt's
  workspace when the listeners go on, so an in-flight /changes from that
  one could take ordinal 0 and be mistaken for the new seed. The
  /changes, item-GET, and /events matchers are now built per attempt
  against that attempt's slug.

- A seed response that was never observed left seedServerTime null,
  which the alignment check folded into "different second". For the
  same-second leg that already meant a retry, but the CONTROL leg would
  proceed on an unknown and claim it had proven a difference it never
  saw. An unknown seed is now its own retry.

Counterfactual re-run after the change: same-second leg fails 4/4
against a reverted query, control passes 4/4; 10/10 green with the fix.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
2026-08-13 12:18:26 -04:00
xarmian 2cb5e0aab0 fix(web): surface swallowed sync failures; defer a mid-sync sync_required (BUG-2508) (#1084)
* fix(web): stop losing sync changes when a consumer fails to apply them (BUG-2508)

Three defects on the incremental-sync path, all reproduced before any fix. The
reproduction IS the regression test (syncCursor.svelte.test.ts) rather than a
throwaway, because what proves the loss and what pins it are the same artifact.

1. THE CURSOR ADVANCED BEFORE CONSUMERS APPLIED. `triggerSync` set
   `lastSyncTime = changes.server_time` and then notified, so a consumer whose
   refetch failed left the cursor past changes nobody had applied — and since
   `/changes` is asked FROM that cursor, the server could never re-deliver them.
   Silent and permanent. The `full_refresh` arm three lines below already had
   the right discipline ("don't advance until pages confirm success"), so the
   file disagreed with itself; the incremental arm now shares it via `deliver`.

2. ASYNC CONSUMERS' REJECTIONS WERE NEVER OBSERVED AT ALL. `notify` wrapped
   `cb(result)` in try/catch, which catches synchronous throws only, and two of
   the five consumers are async. Their rejections did not reach that catch —
   they surfaced as unhandled rejections while the service went on believing
   the sync had been applied. Callbacks are now awaited, so "caught and ignored"
   and "not caught at all" collapse into one honest answer, and the failure is
   logged instead of dropped.

3. A `sync_required` ARRIVING MID-SYNC WAS DROPPED, not deferred. The in-flight
   request was issued before that signal, so its window cannot cover it, and
   nothing re-announces the gap. `triggerSync` now records it and runs one more
   pass; the flag is cleared before each request so a signal arriving during one
   is not swallowed by the pass that predates it.

Plus the second half of the report: the timeline's SSE-driven refresh caught its
failures and did nothing at all with them, leaving the panel quietly missing a
comment somebody else had just posted, with no indication and no retry — the
next refresh only comes with the next relevant SSE event, which may never
arrive. It now logs and retries ONCE on a backoff. Not a banner (this is a
background refresh, and a modal-weight failure surface would be worse than the
bug) and not unbounded retries (the debounce exists because SSE replay can
hammer that endpoint).

Scope held to the triage: `doIncrementalOrFull`'s fallback behaviour is
untouched.

The tests assert the `since` ARGUMENT OF THE NEXT REQUEST, never an end state
(team CONVE-12): "changes lost", "changes never made" and "refetch succeeded
with nothing to do" are indistinguishable by end state, and what separates them
is whether the server can still be asked. A control leg pins that a clean sync
still DOES advance the cursor, so a fix that simply never advances it fails.
Three mutants, each failing only its own tests. The rig itself nearly produced a
false green — the service is a module singleton whose `setWorkspace` issues its
own `/changes` call, and the first version counted it and leaked cursor state
between tests, which made one leg pass on another leg's calls.

* test(web): cover the timeline's SSE-refresh retry, and make its SSE mock fan out (BUG-2508)

The retry added in the previous commit had no test. Adding one required fixing
the harness first: this file's `sseService` mock returned a disposer and dropped
the callback, so the component was subscribed to nothing and any test of how it
REACTS to an event would have passed vacuously. It now fans out, which is the
same mock defect BUG-2509 hit on the attachment bus.

Both legs assert on the REQUESTS issued, not on rendered entries: a timeline that
never refreshed and one that refreshed successfully with nothing new look
identical on screen (CONVE-12). The control leg — a successful refresh must NOT
retry — is what stops a fix that simply retries unconditionally from passing,
which would double every successful refresh on an endpoint the debounce exists to
protect.

Two mutants: removing the retry and dropping the once-only guard each fail only
this test.

* fix(web): fence the timeline's SSE refresh against teardown (BUG-2508)

The retry added earlier in this branch could fire after unmount: a rejected
request schedules it from its own catch, and the identity fence there
(reqSlug/reqWs) is not a teardown fence — a remounted panel can legitimately
carry the same identity, so "same item" never meant "still alive". `onDestroy`
only unsubscribed SSE and left both timers running.

Now `onDestroy` clears the shared timer and latches `destroyed`, which every
continuation that can outlive the mount checks: entry, the success path before
it writes state, and the failure path before it schedules the retry.

Found by an independent review pass on this branch — the leak was mine, introduced
with the retry.

* fix(web): revert the cursor gate; keep the failures observable (BUG-2508)

Scope call after review: keep this bug narrow, and revert the coordinator change
rather than ship it.

The gate ("advance the cursor only if every consumer applied") was correct at the
service boundary and INERT in production, because no consumer reports failure —
verified at four sites, and confirmed by a live leg that behaved identically on
gated and ungated builds. Shipping it would have read as a fix for the reported
bug while changing nothing, which is worse than the open bug: the next person
cites it as handled. Reverting an inert change loses nothing users ever had.
The design half — consumer contract, the poison-consumer case that would pin the
cursor for everyone against an unbounded /changes window, and the
markSynced/onTabResume inconsistencies — is filed as IDEA-2535.

What ships here, all within "surface the failures":

- FAILURES ARE OBSERVABLE. The old try/catch caught synchronous throws only, and
  two of the five consumers are async with their promise discarded — those
  rejections were not "caught and ignored" but unobserved entirely, surfacing as
  unhandled rejections with nothing tying them to the sync that caused them. A
  rejection handler is now attached to whatever a callback returns, and both arms
  log. Deliberately NOT awaited: consumers keep running concurrently and delivery
  stays synchronous, so observability does not smuggle in an ordering change.
- A `sync_required` ARRIVING MID-SYNC IS DEFERRED, not dropped. The in-flight
  request was issued before that signal, so its window cannot cover it, and
  nothing re-announces the gap.
- Cursor semantics are UNCHANGED, and a test pins that deliberately: if someone
  reinstates the gate, it fails and sends them to IDEA-2535 rather than letting an
  inert contract ship quietly a second time.

Three mutants (drop the async observer, drop the sync log, drop the deferral
flag), each failing only its own test, plus a control that a sync with nothing
pending runs exactly one pass.
2026-08-12 21:38:44 -04:00
xarmian d8b68c443e fix(web): clear the attachment NodeView missing-latch on parent restore (BUG-2509) (#1083)
* fix(web): clear attachment NodeView missing-latch on parent restore (BUG-2509)

Archiving an item 404s its attachments without deleting them (DR-13). Any
attachment surface that PROBES inside that window therefore observes exactly
what a deletion produces, latches it as permanent, and stays dead after the
restore. The strip and the timeline already reconcile this themselves via the
`parentArchived` prop plus their own epoch + no-store re-probe; the editor
NodeViews could not — their latch is closure-private state inside a Tiptap
view, unreachable from a prop — so they were routed out of PLAN-2392 3c-iii's
scope and never got the equivalent.

Two distinct defects, both reproduced live before any code was written:

1. The shared HEAD metadata cache memoizes `missing` for the page lifetime, on
   the premise that a settled result is a durable fact about a content-addressed
   row. True for deletion, false for archive: a `missing` observed in the
   archived window is a fact WITH an expiry cached as though it had none. Every
   later reader replays a 404 the server would no longer give — INCLUDING a
   NodeView constructed fresh, which is why remounting the editor did not heal
   the file chip.

2. The NodeView `deleted` latch is cleared only by a uuid swap, and a restore
   does not change the uuid.

Which one bites depends on `canEdit`, which is forced false while archived: an
edit-permissioned user's restore flips the content branch and builds a fresh
editor (healing the image, whose load event repaints it, but not the chip, which
makes no request and reads the poisoned cache), while a viewer keeps the SAME
editor across the whole flip and sees both stay dead.

The fix is a restore channel on the attachment bus — deliberately NOT the mirror
of the deletion channel. Deletion is authoritative and subscribers latch it;
this signal carries NO VERDICT and only prompts a re-ask. Subscribers re-probe
no-store and clear the latch ONLY on an authoritative `ok`, so an attachment
genuinely deleted while its parent was archived 404s and stays dead, and a
mis-routed signal costs one HEAD and changes nothing. That is what keeps restore
from becoming an undo-resurrection vector (DR-17).

`announceAttachmentParentRestored` does both halves because they cover different
populations: the notify reaches surfaces already mounted and latched (a cache
invalidation cannot — their latch is not a cache read they repeat), and the
invalidation covers surfaces built later, which a notify cannot reach because
they did not exist when it fired. Invalidate first, so a subscriber re-probing
synchronously inside the notify is not answered from the entry being dropped.

Verified in a real browser against an isolated instance, on both legs (owner and
viewer, item opened while archived then restored with no reload), plus the DR-17
leg (attachment row genuinely deleted during the archived window — stays dead)
and the control (opened live, then archive/restore — never latches, since the
latch requires the NodeView to be CONSTRUCTED inside the archived window).

Tests: the three bus mocks now fan the restore channel out to subscribers rather
than only recording the subscription — a spy-only mock leaves the NodeView
subscribed to nothing and every reaction test passes vacuously. New coverage for
the cache invalidation, the channel (including the invalidate-before-notify
ordering, asserted through its observable consequence), and both NodeViews' heal
/ stay-dead / routing / teardown behaviour. Each new assertion was mutation-
tested: no-op'ing the invalidation fails 3, short-circuiting either listener
fails the heal tests.

* fix(web): fence the restore probe against deletion, workspace and item-switch (BUG-2509)

Three defects an independent review pass found in the previous commit. The
first is the safety property that commit claimed to protect.

1. DR-17 RESURRECTION, reachable in one interleaving. The deletion bus sets the
   latch synchronously, but the restore continuation fenced only on teardown and
   a uuid swap — so "restore probe starts → delete is confirmed and broadcast →
   probe resolves ok" cleared the latch and repainted a row the server no longer
   has. The browser leg missed it because there the delete preceded the signal,
   so the probe itself 404'd; the hole is only in the overlap. Fixed with a
   monotonic deletion generation captured before the probe and re-checked after —
   a bare `deleted` re-check would not do, since the latch can be set and cleared
   again while one probe is in flight.

   Relatedly, a non-`ok` result was treated as "do nothing" when `missing` is in
   fact the authoritative existence answer the probe asked for. It now latches,
   which also settles two concurrent probes resolving out of order rather than
   letting whichever answered first win.

2. The continuations never re-read the address after the await, so an answer
   about ws-A's copy could heal a node whose editor had since moved to ws-B —
   the same cross-workspace staleness that made these files read the workspace
   off the live reader in the first place. Re-checked on landing, as
   ItemTimeline's probe already does.

3. The ItemDetail edge was keyed on the LEVEL (`itemMatchesRef && isArchived`),
   which also drops when you navigate AWAY from an archived item — so every such
   navigation announced a restore that never happened, invalidating a workspace's
   metadata cache and prompting probes. The latch now carries the item id: a
   restore is "the same loaded, matched item went archived → live", nothing else.
   Extracted as `parentRestoreEdge` and unit tested, because ItemDetail resists
   jsdom mounting and that case is otherwise unpinnable.

Also: the two teardown tests passed vacuously — a leaked listener still sees
`destroyed` and returns, so they stayed green with the dispose call deleted.
They now assert the registry is empty. Mutation testing then caught the same
class in two of my own new tests: the image's mid-probe-deletion and
workspace-change cases asserted only that the placeholder was still visible,
which is true either way until a `load` fires. They now assert the observable
consequence of a wrong heal — the cache-busted reload it would arm.

Every new fence was mutation-tested individually (5 mutants, each failing only
its own tests). Full suite 1496 passed; svelte-check 0 errors; all three browser
legs re-verified against a binary built from this tree.

* fix(web): generation-fence every probe; key the restore signal by item (BUG-2509)

Round two of independent review. Three more findings, all confirmed against the
source; the third means the previous fix was incomplete for a realistic
navigation, not merely unfenced.

1. A STALE PROBE COULD STILL REACH THE LATCH PATH. The previous fence was
   per-CAUSE (did a deletion land?) rather than per-TRANSITION, so it only caught
   one direction. The other: an OLDER probe answering after a NEWER transition —
   the image's archived-window probe still in flight when the restore heals, then
   re-latching what the restore just fixed; and the chip's CONSTRUCTION probe
   (issued inside the archived window, which is the whole bug) landing after the
   restore signal and marking a live chip dead with nothing left to undo it.

   Replaced with one monotonic per-NodeView generation, bumped on every
   authoritative transition — deletion, missing-latch, heal, uuid swap, and
   receipt of a restore signal — captured by every continuation that mutates
   presentation. The invariant is now structural rather than a list of cases:
   a continuation may only act if nothing authoritative happened since it
   started. Bumping on receipt is what invalidates a probe issued before the
   restore, including the early-return path where there is nothing to heal.

2. UUID FENCING WAS BY VALUE, so a swap away and back passed the check again —
   and the deletion listener filters on the CURRENT uuid, so a delete arriving
   while the node pointed elsewhere was ignored. Stale ok + same uuid on return =
   resurrection. The swap now bumps the generation, which is what the
   continuations compare.

3. THE EMITTER MISSED RESTORES THAT HAPPEN WHILE THE PANE IS AWAY. Archive A,
   navigate to B, let someone else restore A, come back: no archived→live edge is
   ever observed by this tab, so nothing was announced and a fresh chip read the
   archived window's cached 404 — the original bug by another route. A per-mount
   edge latch cannot see this, and keying it to the item id does not help; the
   memory has to outlive the mount. Replaced `parentRestoreEdge` with
   `archivedItemRegistry`: mark an item when seen archived, announce when it is
   next seen live. That subsumes every case the latch handled (restore in place,
   navigate away, mount-on-archived) and covers the one it structurally could not.

Verified live, same document throughout (asserted, not assumed — the leg stamps
the document and checks the stamp survives, since a full reload would drop the
very state under test). Control: the same leg against a binary built from the
PREVIOUS commit's emitter leaves the chip dead on return while the image heals,
which is exactly the predicted symptom — so the leg discriminates rather than
passing for free.

Tests: registry unit tests including both the false positive and the miss; the
stale-probe orderings for both NodeViews; the uuid away-and-back case; and the
restore probes' `cache: 'no-store'` (the mocks were discarding the options
argument, so the point of the re-probe was untested). Ten mutants total across
both rounds, each failing only its own tests — one (the uuid-swap bump) survived
first time and got the test it was missing.

* fix(web): make the latch fence structural; correct three stale comments (BUG-2509)

Round three of review. The generation fence I added covered the two probes I had
been looking at and missed three others that also latch: both toolbar MIME probes
and the activation probe. Same defect as the one already fixed — a probe issued
inside the archived window answering 404 after the restore healed the node — at
call sites I had not enumerated.

Rather than patch three more sites, the fence moved INTO `latchMissing`, whose
signature now REQUIRES the captured generation. Every path into it is an async
probe answering a question it asked earlier, and a latch is destructive and
permanent, so "the caller will remember" was the wrong shape: an unfenced call
site is now a type error rather than a bug found in review. Fixing this class one
site at a time is what produced the miss.

Three comments were left saying things that are no longer true, which in this
file is not cosmetic — the comments are how the next reader learns the rules:
  - the PLAN-2411 seam note still said the restore channel was "stated, not built
    here" and that only that future channel could clear the latch. This branch is
    that channel. Rewritten to state what actually holds now (the signal never
    clears anything; only a server `ok` does).
  - my own generation docstring claimed EVERY mutating continuation captures it,
    which the transforms and the activation-open path do not. Restated to say
    what it governs (the latch and the heal) and what it deliberately does not
    (opening a viewer, which `activationSeq` owns; rotate/crop, which mint a new
    attachment).
  - the metadata cache's "no staleness concern" predates the distinction this bug
    turns on. Split: a settled `ok` cannot go stale, a settled `missing` can,
    because it describes reachability rather than contents.

Two review findings are DECLINED, recorded in the code where the next reader will
ask. Re-checking `address().itemId` after the await: itemId is routing, not
ownership, and the probe's answer is about the ATTACHMENT — fencing on it would
imply a relationship the code does not have. Eviction for the archived-item
registry: it holds one uuid per archived item viewed, and dropping a mark early
reintroduces this bug silently for long sessions only, which is the worse trade.

The new toolbar-probe test passed vacuously at first (`selectAll` does not build
the toolbar, so the probe under test never ran and the stale-release handle was a
no-op default). It now drives a real NodeSelection and asserts the probe fired.
Twelve mutants across the three rounds; two survived first time and each got the
test it was missing.
2026-08-12 18:57:35 -04:00
xarmian 7b46894413 fix(e2e): silence cross-actor SSE creation toasts suite-wide (BUG-2334)
The e2e suite shares one pad instance and one workspace, so items seeded
by OTHER concurrently-running specs arrive over SSE and stack
"X created: ..." info toasts bottom-right — directly over bottom-right UI
(the graph drawer's detail card), turning unrelated specs' clicks into a
race. pane-content-link-anchors:238 paid a ~40-minute rerun tail at
nearly every merge gate.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two semantics pin the accounting:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

88 unit tests; 23 hand-built mutants killed. npm run check clean;
full web suite 1180 tests green.
2026-08-08 06:27:25 +00:00