Commit Graph

629 Commits

Author SHA1 Message Date
xarmian 7bb9c4acb1 fix(attachments): close the final-review findings across the feature
From the orchestrator's full-diff pass over main...HEAD — the altitude
per-task reviews structurally cannot reach.

- Opening the panel on an ALREADY-archived parent left Open, Download
  and Copy link enabled against endpoints that 404: the host only
  handled the archive TRANSITION, and the strip's event carries complete
  metadata, which is exactly what lets the panel skip its probe. The
  panel is now told the parent is archived and probes anyway, landing in
  the authoritative missing state it already knows how to render. It
  probes through the INVALIDATING path, because reachability is an
  existence question and the cache can hold an `ok` observed before the
  archive — the same lesson as the image placeholder earlier on this
  branch.
- A deleted chip stayed inert after its node was repointed at a
  different attachment: the uuid-swap path cleared `deleted` and the CSS
  but not `disabled`, giving a chip that announces itself as live and
  does nothing. Reachable through a collaborative peer's edit.
- actions.ts claimed to be "rendered twice". It has one consumer today;
  the viewer is phase 3a. Says so now, including that the image
  NodeView's threaded address is held open for the same phase — a list
  with a single consumer is worth re-justifying if 3a stops coming.
- The shared confirmation claimed to own the prompt wording while
  StorageTab built its own inline. Both builders now live in that module
  side by side: an item surface can check the body it has and must hedge
  about the ones it cannot, while a workspace-wide list has nothing to
  check and should say what happens to the blob instead.
- Descriptor `description` was never rendered; it is the row's tooltip
  now rather than a dead field.

Both behavioural fixes are mutation-tested.
2026-08-04 14:53:04 +00:00
xarmian c4189b1dc6 fix(attachments): one in-app delete confirmation, everywhere (TASK-2425)
The strip's hover `×` raised a browser-native `window.confirm` while the
options panel — and the rest of the item UI — drilled down to an in-app
sub-view. Two confirmation styles for one object is exactly what DR-18
exists to prevent, and the settings Storage tab's row Delete was on a
native `confirm()` too.

All three now render one shared `AttachmentDeleteConfirm`: prompt as
`role="presentation"` carrying an id, `aria-describedby` back-reference
from the destructive row, Cancel FIRST so the focus handoff can never
land Enter on Delete, destructive row last. It renders rows only — each
surface supplies its own `Menu`, so ESC ordering, outside-click, portal
placement, focus return and the mobile sheet swap stay the app's
existing behaviours rather than a second implementation.

Both warning arms carry through verbatim, from one shared builder: the
referenced arm and the hedged one, which stays hedged because the check
can only ever speak for the item it has. The Storage tab keeps its own
wording (the GC grace period) — a reference check has no meaning in a
workspace-wide list — but shares the shape.

The delete REQUEST paths are untouched: same entry paint fence, same
`viewFence.begin()`, same optimistic removal and single-row rollback,
same 404-is-authoritative arm, same `announceAttachmentDeleted`. One
addition each: `window.confirm` blocked the thread, so the entry fence
was still true by definition when it returned — an in-app confirmation
does not, so the fence is re-checked where the request is actually sent,
and an open confirmation is abandoned when the view changes under it or
another surface deletes the row.

Also fixes an unhandled rejection the suite surfaced: `Menu` places
itself in a `tick().then()` that can run after its block is torn down,
so every prop expression reading the pending state needs `?.`.

Tests: the ~18 strip tests (and 4 Storage tab tests) that spied on
`window.confirm` now drive the real rows; every message-arm, fence and
rollback assertion is preserved. New coverage for the confirmation's
shape, Cancel's focus return, the confirm-time fence, and abandonment
on switch / external delete. The e2e strip spec drops its `dialog`
handler and pins the 24×24 target size in a real browser.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-04 13:48:05 +00:00
xarmian 74589e98bb fix(attachments): make the live chip a button, not a link (TASK-2424)
From the orchestrator's pass on TASK-2424. The chip opens the options
panel — it does not navigate — but it was still an <a href download> that
only intercepted primary click. Middle-click and aux-click therefore
opened or downloaded the file straight past the panel, which is the exact
accidental download the panel exists to prevent, and screen readers
announced a link for a control that opens a menu.

A button has no URL to activate, so the bypass cannot exist rather than
being intercepted, and it matches the strip's file tile — the same
control, now with the same semantics. renderHTML stays an <a download>:
that is the clipboard / read-only shape, where a link is honest and there
is no panel to open.

Deleted chips now use `disabled` rather than a dropped href: a disabled
button receives no click or keydown at all, where the anchor's handlers
still ran and had to swallow events. It is also blurred explicitly first,
since a chip the user is focused on when another surface deletes the row
would otherwise strand focus on an inert element. The keydown suppression
moved ahead of the deleted bail so a stray Enter can never reach
ProseMirror's keymap and split the paragraph the chip sits in.

.file-chip carries the button reset explicitly (font: inherit), since the
UA's 13.33px Arial would otherwise shrink the live chip away from the
read-only one.
2026-08-04 13:09:07 +00:00
xarmian 3d45418c52 Merge branch 'task/2424-panel-routing' into feat/attachment-options-panel 2026-08-04 12:58:29 +00:00
xarmian 749abbf6e4 fix(attachments): fence the panel's teardown and its deferred close
Three from the orchestrator's independent pass on the panel, all the same
shape as the two the panel's own tests caught: something the panel
started keeps running after the host has moved on.

- The host destroys the panel by nulling its request, but a delete
  already in flight still called onclose() when it resolved — closing
  whatever panel was open BY THEN. Attachment A's delete could dismiss
  the panel the user had just opened on B. Fixed at the boundary: the
  host's close handler is bound to the request it was rendered for and
  ignores a call from any other. The panel also invalidates its fences
  on destroy, so the continuation reads stale rather than merely being
  ignored, and resolves a confirmation still on screen — an unresolved
  one strands the descriptor's await forever.
- The deferred close after an anchor navigation (deliberately a
  macrotask, so the download is not cancelled) was unfenced: reopening
  on another attachment before the timer fired let the old timer close
  the new panel.
- A forced revalidation that came back transient left the authoritative
  'missing' latched, and the missing branch suppresses Retry — 'no
  longer available' with no way to ask again. Reachable exactly when a
  panel is opened on an already-archived parent and the item is then
  restored, which is the case DR-14 added the revalidation for.

The bound close handler is mutation-tested: unbinding it fails the
item-switch test.
2026-08-04 12:58:23 +00:00
xarmian 1a42a6df44 feat(attachments): open the options panel from tiles and chips (TASK-2424)
The behaviour change users actually see (PLAN-2392 DR-1 / DR-12 / DR-16).

A non-image strip tile stops being an `<a download>` — one tap and it was
in your Downloads folder, with no way to see what a file IS first. It is
now a real `<button>` that emits the open-panel event with the tile as the
anchor and all three metadata fields (the strip always has them from its
list row). The whole tile is the trigger and no `⋯` affordance is added,
per DR-1; a native button is what guarantees DR-12's "Enter and Space
both activate, exactly once" — the UA converts both to a single click and
already eats Space's page scroll, so there is no hand-rolled keydown
handler racing the click one.

The live editor chip opens the SAME panel instead of `window.open`ing the
download URL, stamped from the NodeView's address reader at emit time.
Its keyboard contract is hand-rolled because it lives inside a
ProseMirror editable region: an un-suppressed Enter bubbles to the
editor's split-block keymap, which preventDefaults it and thereby cancels
the anchor's own activation click. So Enter and Space are both handled on
keydown and both cancelled — a cancelled keydown produces no activation
click, which is what keeps the count at one. A chip in the deleted state
stays inert AND unfocusable (no href, and no tabindex is ever set) rather
than being a focus stop that does nothing.

Both surfaces' accessible names now carry filename, type and the ACTION
("Options for spec.pdf, PDF, 1.5 KB") — the only signpost for the changed
behaviour, since DR-1 deliberately adds no visible one.

Image tiles and `lightboxImages` gate on `canOpenInViewer`, not `isImage`
(DR-16): an `image/svg+xml` row renders as a FILE tile, opens the panel,
and is not a member of the viewer's set, because SVG can carry active
content and a legacy / mislabelled / sniffed row can arrive wearing an
`image/*` label.

Folded in: `.att-tile` gains `font: inherit`. The file tile is the one
with text in it, and as a button the UA font (13.33px Arial) would have
replaced the inherited one — `.att-name`'s 0.6em measured against it is a
visibly smaller, differently-faced filename than the anchor rendered.

The strip test that pinned the tile as an `<a href download>` is
deliberately falsified and replaced. New coverage: the emitted payload,
single activation per key on both surfaces, the SVG file-tile path, and a
Tiptap-driven chip suite (emit-time addressing, click suppression, the
dead-chip contract).

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-04 02:58:04 +00:00
xarmian 01e2bc3b5d Merge branch 'task/2423-details-panel' into feat/attachment-options-panel 2026-08-04 02:45:35 +00:00
xarmian b8909befdf feat(attachments): an options panel for files (TASK-2423)
Tapping a file used to do the most destructive-adjacent thing available:
a strip tile was a bare `<a download>`, so one tap put the file in your
Downloads folder with no way to see what it was first. This is what a tap
opens instead — `AttachmentDetailsPanel`, plus the one host that owns it.

PLAN-2392 phase 2, wave B. Nothing routes into the panel yet; the strip's
tiles and the editor's chips start emitting the open event in TASK-2424,
so the panel is driven here through its host and the events bus.

Presentation is the existing `Menu` with `sheetOnMobile` — a popover on
desktop, a BottomSheet at the mobile breakpoint (DR-6). No new overlay
primitive, so ESC ordering, outside-click, placement and the sheet's focus
handling are the app's existing ones rather than second implementations.

The actions are NOT defined here: they are rendered from the shared
descriptor list (DR-5), choosing between MenuItem's anchor and button
branches on the descriptor's own `element` discriminant and never calling
`run()` on an anchor. Adding an action stays a one-descriptor change.

It opens IMMEDIATELY and completes the metadata after (DR-2, DR-10). The
event's filename / mime / size are nullable by contract, so the panel
paints what it was handed and fetches the rest itself: `ok` fills the gaps,
`missing` (404) latches an authoritative "no longer available" with every
action inert, and `transient` shows an inline error beside the row it
already knows, with a Retry that goes through `revalidateAttachmentMetadata`
— a plain refetch would replay the cached failure and look broken.

Delete is an in-app drill-down sub-view (DR-18), the item menu's shape
exactly: prompt as `role="presentation"` with an aria-describedby
back-reference, Cancel FIRST, destructive row last, and the strip's
contextual "still used in this item's content" warning carried through
(read at confirm time from the LIVE editor markdown, since the persisted
body lags). It is wired as the delete descriptor's `confirmDelete` promise
rather than as a bespoke path, so the descriptor's identity snapshot and
permission re-check across the confirmation stay in force. The unreferenced
arm stays hedged: this can only speak for the HOST's content, and the
event's `itemId` is routing, not ownership.

The host is `ItemDetail`, through a small `AttachmentPanelHost` it mounts
beside the strip. It consumes an event only when BOTH `itemId` and
`hostToken` are its own (DR-8), and supplies `mutationsEnabled` itself —
never the NodeView's (it has no mutation context) and never the timeline's
`canEdit` (which ignores `peeking` and would let a peeked pane mutate). The
host is a component rather than a block inside ItemDetail because the
addressing rule has to be testable with two hosts mounted at once, which is
what the pane host does at runtime.

Parent lifecycle (DR-14): an archived parent's attachment fetch returns a
generic 404, so archive CLOSES the panel and restore REVALIDATES it rather
than assuming the previous state holds. The strip sits outside ItemDetail's
keyed lifecycle block, so this is added, not inherited; it arrives
declaratively as `parentArchived`, following the item ItemDetail already
refetches on the SSE lifecycle events.

Long filenames and RTL are handled with logical properties throughout,
`min-width: 0` on every flex child holding the name, and the full unelided
filename in both `title` and the panel's accessible name (DR-13).

No `state_generation` and no Undo (DR-19) — Delete behaves exactly like
today's tile Delete; PLAN-2411 adds the generation token and the Undo toast
to all three entry points at once.

Also here:
- `describeAttachmentType` in the shared display helpers, built on
  `iconForAttachment` so the words and the icon beside them cannot disagree
  about what a file is.
- `liveEditorMarkdown` extracted in ItemDetail — the strip and the panel now
  read the live body through one accessor instead of two copies.

Tested through the host (20 jsdom cases): addressing with two hosts mounted,
open-with-partial-then-complete, all three metadata arms, Retry's
invalidate-before-refetch, host-supplied permission for peeked vs master,
the full confirm/cancel/failure delete paths, both warning arms, archive-
closes / restore-revalidates, item switch, and re-targeting in place. Focus
entry and return, background inertness, real placement, the sheet swap and
Enter/Space activation are browser-only and belong to phase 3d.
2026-08-04 02:44:57 +00:00
xarmian 2501046777 fix(attachments): fence transform failures and state what itemId means
Two from the orchestrator's security/Svelte-angle Codex pass.

A rotate or crop that FAILS was unfenced where its success path was
fenced, so an error from work on attachment A could alert the user after
they had already switched to B. Same guard, both catches.

The other finding read the event's itemId as an authorization claim and
concluded a stale chip in a reused composer could delete an attachment
belonging to another item. Checked rather than assumed: the delete
endpoint is never told which item the client thought it was acting from,
and the server authorizes per attachment against the parent it resolves
itself — visibility first, then edit permission (handlers_storage.go).
So it is routing, not ownership, and the two legitimately differ.

Declined as written, but the confusion is the finding: itemId now says
what it is at the point of definition, including the one place the
distinction leaks into UX — a panel can only speak for the host's own
content, so its in-use warning has to stay hedged.
2026-08-04 02:39:50 +00:00
xarmian ebe531ebaa fix(attachments): make the host address a reader, not written-in options
The addressing fix in the previous commit could not have worked. Tiptap's
`options` is a getter returning a fresh spread on every access
(@tiptap/core 3.22.5, dist/index.cjs:3452), so `ext.options.itemId = next`
mutates a temporary and is discarded — an assignment that looks exactly
like working code.

So the address stops being two strings pushed in after the fact and
becomes a reader the host supplies once and keeps honest: a closure over
its own live props, called at emit time. That is correct for a host that
is remounted per item (the body editor) and one that is reused across an
item switch (the comment composer) without either knowing which it is.

New $lib/attachments/hostAddress.ts states the contract; its test pins
the dependency behaviour that forces it, so a future Tiptap bump that
makes options writable fails a test instead of quietly inviting the
mutation approach back. The event predicate now reuses isAddressable so
'both halves required' is stated once.

Also completes the NodeView teardown fence (both MIME probe
continuations and swapNodeUuid could run after destroy) and adds the
stable-async-confirm and ordering cases the delete tests were missing —
without them, a regression dropping every async-confirmed delete passed.

The same dependency trap makes the editor's existing capabilities push a
no-op; that is pre-existing and independent, filed as BUG-2426.

All three from the orchestrator's second fresh-angle Codex pass.
2026-08-04 02:17:48 +00:00
xarmian 042bd7e477 fix(attachments): close four review findings across the wave-A surfaces
From the orchestrator's fresh-angle Codex pass. All four are the same
shape — something read after an await, or captured once and never
refreshed — on a component tree built around a no-{#key} item switch.

- The delete descriptor snapshotted identity AFTER its confirmation, so
  an async in-app confirm (which is what DR-18 asks for) left a window
  where the user could switch items and delete the attachment they were
  no longer looking at. Snapshot first, re-check the gate and the
  identity on the way out.
- MenuItem's anchor rows had no Space activation. Native anchors take
  Enter only, and role=menuitem does not add it, so Space would do
  nothing on Download and Open while working on every button row beside
  them.
- The image NodeView had no destroyed flag, so a HEAD probe in flight at
  teardown could latch a placeholder onto detached DOM. The chip NodeView
  has always had one.
- CommentEditor configures its extensions once in onMount, but the
  composer is deliberately reused across an item switch, so its chips
  kept emitting events addressed to the PREVIOUS item — which the host
  then correctly ignored, i.e. a tap that silently did nothing. Push the
  addressing onto the live options, the same way capabilities are pushed.

The first two fixes are mutation-tested: reverting either fails the new
test.
2026-08-04 02:05:43 +00:00
xarmian 37dd850f9b refactor(attachments): let the descriptors own the preview predicate per review
The Open descriptor took `canPreview` from its context because the
descriptor list and the MIME predicate were built in parallel and could
not import each other. Both are on the branch now, so the injection is
just a way for one call site to be handed a looser answer — DR-16 puts
every "what can this MIME do" question in one module precisely so that
cannot happen, and an injected predicate admitting image/svg+xml would
reopen the hole the exact allowlist closes.

Imports canBrowserPreview directly, drops the context field, and pins the
SVG case in the descriptor tests.

Also states plainly what the timeline's transient re-probe does and does
not deliver: it makes the attachment eligible on the next effect run, it
is not a scheduled retry. Both from the orchestrator's cross-task pass.
2026-08-04 01:03:14 +00:00
xarmian 2dfdfe2244 Merge branch 'task/2422-action-descriptors' into feat/attachment-options-panel 2026-08-04 00:53:45 +00:00
xarmian ca3428fa07 Merge branch 'task/2421-host-token' into feat/attachment-options-panel 2026-08-04 00:53:41 +00:00
xarmian ca0f4d2957 fix(attachments): revalidate before answering an existence probe (TASK-2420)
An <img> whose load just failed asks probeForMissing whether the row is
gone. It was reading fetchAttachmentMetadata, whose page-lifetime cache
holds a prior `ok` observation — so an attachment deleted after that
observation still read as live and the permanent placeholder could never
latch. A cache of "what is this?" structurally cannot answer "is this
still there?".

Adds revalidateAttachmentMetadata (invalidate, then fetch) and routes the
existence probe through it. Caching of ok/missing is unchanged for the
metadata question, and DR-10's Retry gets the invalidate-before-refetch
primitive it needs.

Found by the orchestrator's Codex pass on TASK-2420.
2026-08-04 00:53:38 +00:00
xarmian 6047b407b6 feat(attachments): shared action descriptors and MenuItem anchor support (TASK-2422)
PLAN-2392 DR-5: the panel and the viewer share one action list only if the
list IS the source of truth, so open / download / copy link / delete become
descriptors in web/src/lib/attachments/actions.ts. Adding an action means
adding one descriptor; both renderers consume the same set.

The element is part of the contract: Download stays a real `<a download>`
because the server sends an inline disposition for most accepted types (a
plain navigation would view rather than save — DR-16), and Open needs new-tab
/ middle-click semantics. So the descriptor type is a union discriminated on
`element`: anchors carry href/download/target/rel and no run() (the browser
performs the action; a renderer calling both would fire it twice), buttons
carry run(). Open is omitted entirely — not disabled — for types a browser
cannot preview, via a `canPreview` predicate taken from the context rather
than imported, keeping MIME capability with the display helpers.

Copy link copies location.origin + downloadUrl(...) because downloadUrl is
relative, and names itself "Copy workspace link" so the semantics are honest:
it is not a share link (DR-5a). Delete behaves exactly like today's tile
delete — api.attachments.delete plus announceAttachmentDeleted, with a 404
treated as authoritative — and deliberately carries no state_generation and
no undo; that wiring lands in PLAN-2411 across all three entry points at
once (DR-19).

MenuItem gains the two capabilities the panel needs, both additive: an icon
SNIPPET alongside the string icon (the string is interpolated as text, so SVG
markup would render as literal angle brackets — DR-3b), and an anchor branch.
A disabled anchor falls back to a disabled button: `<a>` ignores `disabled`,
stays focusable and still navigates, and Menu's keyboard navigation skips
rows via `[role^="menuitem"]:not(:disabled)`, which no anchor can match.

Tests cover the descriptor contract (open absent for a .zip, present for a
PDF; download's filename attribute; the absolute same-origin copy URL and its
clipboard-failure path; delete disabled without mutations, its 404-as-success
path and its error propagation) and MenuItem's unchanged button rendering
alongside the new snippet and anchor branches.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-04 00:46:40 +00:00
xarmian 51b90003a9 feat(attachments): thread host identity into the attachment surfaces (TASK-2421)
The addressing layer the attachment options panel needs (PLAN-2392 DR-2 /
DR-8). No visible behaviour change — nothing consumes the channel yet.

`$lib/attachments/events.ts` gains the panel channel:
`AttachmentPanelOpenEvent` ({attachmentId, itemId, hostToken, anchor,
filename, mime_type, size_bytes} — the three metadata fields nullable
because a chip fills them from an async HEAD probe that may be incomplete
or failed, while the strip always has all three from its list row),
`notifyAttachmentPanelOpen`, `registerAttachmentPanelListener`,
`createAttachmentHostToken` and `isAttachmentPanelEventForHost`.

The two identity fields are the point. The bus is module-global, but
`ItemDetail` is mounted more than once at a time — the pane host runs a
master plus a peeked pane, which can be showing the same item. `itemId`
alone would let both hosts consume one NodeView's event (two panels for
one tap, one of them permissioned by the wrong host's mutationsEnabled).
So a host consumes an event only when BOTH fields are its own, and a
null/empty token on either side matches nothing — an unconfigured
NodeView must not be able to address every host at once.

`ItemDetail` mints ONE token per mount (a plain const, stable across the
no-{#key} item switch) and passes it to every attachment surface it owns:
the strip (which had no token path at all), both `Editor` branches, and —
through `ItemTimeline` and `TimelineCommentCard` — every `CommentEditor`.
`itemId` + `hostToken` are threaded into `AttachmentChipOptions` and
`AttachmentImageOptions` and wired at both configure sites.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-04 00:46:11 +00:00
xarmian 2ec6c954e6 feat(attachments): typed metadata result and MIME capability helpers (TASK-2420)
`fetchAttachmentMetadata` collapsed 404, every other non-2xx and network
throws into a single `null` — and cached it. Two consequences: nothing
could treat "gone" as authoritative (so editor undo resurrected deleted
attachments as live-looking nodes), and a one-off blip was sticky for the
page's lifetime.

It now returns a discriminated result: `ok` carries mime/size, `missing`
is the authoritative 404, `transient` is everything else. `ok` and
`missing` stay cached; `transient` is evicted the moment it settles, so a
retry re-issues the HEAD while concurrent callers still share one
in-flight request (PLAN-2392 DR-17).

Both NodeView consumers act on the split. The chip latches the
missing/deleted treatment on `missing` and leaves the filename-guess icon
alone on `transient`. The image NodeView probes on load failure — an
<img> error event carries no status code, so a deleted row and a network
blip are indistinguishable there — and only a 404 latches the permanent
placeholder; the toolbar's MIME probe latches too, since it may beat the
image to the answer. ItemTimeline drops its probed-mark on `transient` so
a blip doesn't permanently strand an entry's metadata.

Adds `canOpenInViewer` (DR-16: exact five-type raster allowlist, not an
`image/` prefix — SVG carries active content and TIFF/HEIC may not decode)
and `canBrowserPreview` (DR-5: that set plus PDF and text/plain) next to
`isImage`, which survives unchanged as the general picture predicate.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-04 00:44:16 +00:00
xarmian 783e9ef0f8 refactor(attachments): extract the view-identity fence into one module
Four review rounds running found the same bug class: a continuation
resuming after an await and writing state that belongs to a view the
user has already left. Each round fixed instances; the next found more.
The tripwire an earlier round set has been hit, so the invariant is
hoisted into one implementation instead of N call sites agreeing by
convention.

web/src/lib/attachments/viewFence.ts owns it now:

  - viewIdentity(read) — the ONE place a component states what names its
    view. Returns tokens carrying a SNAPSHOT of the parts, so a
    continuation reads the workspace it was issued for off the token
    rather than off the live prop. A missing part voids the whole key,
    and a null key never matches — a half-identified view cannot pass a
    fence.
  - createFence(identity) — generation + identity. begin() coexists with
    its siblings; restart() supersedes them; invalidate() ends them.
    Used twice per surface, for fences 1 and 2.
  - createPaintFence(identity) — fence 3, the paint-time entry check.

All three stay distinct: a prior round established that collapsing any
two loses either A→B suppression or same-item-Retry reconciliation.

Both consumers now build all three from a single identity declaration,
so no call site can restate a shorter one — which was the recurring
mistake (the workspace half kept going missing).

Two outstanding findings fixed alongside:

  - StorageTab delete was not workspace-fenced: it used the live wsSlug
    after its await, so an A→B switch mid-request let the success/404
    handling toast and reload against B. It now takes the workspace off
    the PAINTED identity (so the DELETE targets the row the user
    actually clicked), refuses a click whose paint is already stale, and
    fences the toast + reload. The broadcast stays ahead of the fence: a
    global (workspace, id) side effect, not a write into this view.
  - The strip's pendingUploads could resurrect externally deleted rows:
    the buffer was retained indefinitely and no successful response ever
    consumed it, so a deletion from another tab, followed by a load that
    legitimately returned no row, merged the stale upload back in — and
    kept doing so. A response is now treated as authoritative about the
    entries the buffer already held when that request went OUT; entries
    announced while it was in flight (the buffer's actual purpose) are
    untouched.

No behaviour change from the refactor: all 592 existing tests pass
unmodified. 18 added — 14 unit tests on the module, 2 per fix. Every new
test mutation-tested.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-03 23:39:48 +00:00
xarmian 3b4331dbe6 fix(attachments): reload storage tab on workspace change (TASK-2418)
Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-03 12:40:59 +00:00
xarmian 66700730d0 fix(attachments): fence delete at entry and complete the view-identity model (TASK-2418)
Second final-review round on the attachment strip. The previous commit split
request identity from view identity; this closes the holes that split left.

- handleDelete is now fenced at ENTRY against `paintedView`, the (workspace,
  item) the clicked tile was actually painted for. Props update synchronously
  and the load effect repaints later, so a click in that window could send a
  DELETE while the user was already on another item or workspace — and the
  `viewChanged` check in the catch runs after the request, so it could suppress
  the rollback but never unsend the call.
- A 404 from the delete now broadcasts BEFORE the view fence. The row really is
  gone; that fact is global, and skipping the broadcast left every other mounted
  surface stale with nothing left to correct it. Only the local rollback/toast
  stay view-scoped.
- The upload listener consults the tombstone set, so a re-announced upload can
  no longer resurrect a deleted row into `attachments` and `pendingUploads`.
- `switchedAway` compares the (workspace, item) pair, like `viewChanged` and
  `viewKey` already did.

Documents the resulting model at the top of the component: three fences for
three distinct questions — is this response current, may this continuation
reconcile, does this control belong to what is on screen.
2026-08-03 12:09:39 +00:00
xarmian 4b2fbe55df refactor(attachments): separate view identity from request generation per final review
Final full-diff review of PLAN-2392 phases 1/1b found four issues the
per-task reviews structurally could not see.

P2 — the strip fenced its delete mutation on the LOAD generation, which a
Retry also bumps. A delete failure landing during a same-item Retry was
therefore mistaken for an A→B item switch and silently swallowed: no
rollback, no toast, no 404 broadcast. Split the single counter in two —
`loadGeneration` (per request, bumped by every effect run including
Retry) still fences load responses; a new `viewGeneration` (bumped only
when the view actually changes, plus onDestroy) fences mutations. The
effect cleanup deliberately bumps only the request generation, since it
also runs before a Retry's re-run.

View identity is the (workspace, item) pair, not the item: `wsSlug` is
reactive and the strip survives a workspace change, so an item-only key
would read that change as a same-item Retry. `viewKey()` now backs the
retry marker, the painted-error owner, and the mutation fence alike.

Exposed by the new test: the rollback splice was not idempotent. A
reload that restored the row while the delete was in flight made it
duplicate the id and the keyed each block threw. The rollback now skips
the re-insert when the row is already back, and still toasts.

P2 — Storage's Retry now performs the same attachment metadata-cache
invalidation the strip's does, so a failed HEAD cached during the outage
doesn't stay poisoned on chips and inline images after recovery. While
there, loadList captures and re-checks its request workspace: the
generation alone couldn't tell a superseded workspace from the current
one, and both the rows and the cache keys are workspace-scoped.

P3 — StorageTab's `anyFilterActive` re-listed the filter fields instead
of reusing `selections()`; one projection now, so they cannot drift.

P3 — the deletion tombstone set survives a Retry and rides a
workspace-wide bus, so it is bounded like the list buffers, shedding
oldest-first (the newest tombstone is the one still racing a response)
and refreshing an id's age when it is re-announced.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-03 11:30:17 +00:00
xarmian cccb9d5858 fix(attachments): bound strip buffers and surface load failures (TASK-2418)
MAX_FETCH was documented as a cap but only ever reached the fetch `limit`:
the upload path prepended unconditionally and `pendingUploads` was itself
unbounded, so a long paste session grew the in-memory list — and the
lightbox set it feeds — without limit. Every growth path now runs through
`capped()`: the load-time merge, the upload event, the pending buffer that
rides on top of the merge, and the failed-delete rollback (PLAN-2392
DR-11).

A failed list fetch used to render as "no attachments", making a broken
strip and an empty one indistinguishable. It now shows a compact
"Couldn't load attachments · Retry", and Retry invalidates the shared
per-attachment HEAD-metadata cache before refetching — that cache latches
`null` on failure for the page lifetime, so a naive retry would replay the
cached failure on every surface that probed during the same outage
(DR-10). Retry also keeps what the failure preserved (optimistic uploads,
deletion tombstones) rather than clearing like an item switch. A delayed
loading row makes loading, empty and failed three distinguishable states
without flashing a block above the editor on the common un-attached item;
empty still renders no section at all (DR-18).

The header shows the true count, `50+` once rows exist past the bound, and
the overflow continuation is now item-scoped: "View all (N)" links to
`?attachment_item=<id>#storage`, the settings route passes it through and
owns clearing it, and StorageTab seeds its existing `item_id` filter from
it with a visible, clearable scope chip plus its own loading/error states.
The count is tracked as a delta beyond the strip, anchored on the server's
`total` and corrected for rows the page reported as deleted and for
uploads the page predates — so local deletes and uploads keep it honest.
Two residuals are deliberate and commented at the call site: a deletion of
a row PAST the bound can't be attributed (the bus is workspace-wide) and
may overstate by one until the next load, and uploads shed by the pending
buffer during a single in-flight request are uncounted because counting
them double-counts the ordinary case.

The test asserting a failed fetch shows no visible error is deliberately
falsified and replaced.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-03 03:00:47 +00:00
xarmian 6f8105b01d refactor(attachments): consolidate icon helpers onto an SVG set (TASK-2417)
Replaces the three independent emoji icon helpers on the live attachment
surfaces with one mapper and one monochrome SVG icon set (PLAN-2392 DR-3,
DR-3a, DR-3b).

- display.ts: categoryIcon -> iconForAttachment(mime, filename), returning
  an icon identifier rather than an emoji. MIME first, filename extension
  second, generic file last -- never a question mark. isImage and
  formatBytes keep their signatures; StorageTab imports all three.
- attachments/icons/: one currentColor-driven icon per format family, with
  TWO render paths over one path table -- AttachmentIcon.svelte for Svelte
  call sites, iconSvg() for the editor chip, which builds DOM imperatively
  and cannot mount a component.
- attachment-chip.ts: iconForMime, iconForFilename and its local formatBytes
  deleted. The call site keeps its hide-zero/unknown-size conditional; the
  shared formatter renders "0 B" and does not grow a mode (DR-3b).
- mime-families.json: the shared MIME -> family map, inside the web root
  because vitest cannot read outside it. A Go test asserts the server upload
  allowlist is fully covered by it (and carries no strays), so the two lists
  cannot drift silently; the web test covers one representative MIME per
  family plus the unknown-MIME and no-extension cases.

CopyItemDialog and markdown/attachments.ts are deliberately untouched.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-03 00:46:24 +00:00
xarmian 2dfce6ca09 refactor(web): centralize attachment deletion + upload mapping per final review
Two duplications the per-commit reviews couldn't see, caught by the
final full-diff pass.

announceAttachmentDeleted(wsSlug, id) replaces the notifyAttachmentDeleted
+ invalidateAttachmentMetadata pair that four call sites were repeating
(the strip's 204 and authoritative-404 paths, and StorageTab's two). Both
halves are needed every time, so a future delete surface calling only one
would silently stop propagating.

toUploadedAttachment() replaces the identical hand-written mapping of
AttachmentUploadResult to the bus DTO in Editor.svelte and
CommentEditor.svelte — the shape they had already been duplicating is
exactly how two upload paths drift.

No behavior change; gates and the e2e are unchanged and green.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 14:09:25 +00:00
xarmian a99e71afe4 feat(web): refresh the attachment strip on upload (TASK-2385)
A file dropped or pasted into the editor now appears in the item
attachment strip immediately, instead of waiting for the next load of
the item (PLAN-2382 phase 3).

The task specified threading an onAttachmentUploaded callback down
through both <Editor> branches. Implemented on the attachment event bus
instead: TASK-2384 already introduced one for deletions, the strip
already subscribes to it, and reusing it avoids prop-drilling a second
channel through a component that has no other reason to know about the
strip. The deletion module is renamed $lib/attachments/events.ts to
cover both directions.

The upload closure captures the item id at upload START -- the promise
outlives an A->B switch even though <Editor> is keyed on item.id, and
AttachmentUploadResult carries no item_id, so that is the only point
where the association is known. Uploads without item context are not
announced: the server leaves item_id NULL for those, so an optimistic
tile would vanish on refresh.

The strip's internal row type is narrowed to what a tile renders. The
upload response has no storage_key / content_hash / created_at, and
fabricating them to satisfy AttachmentListItem would be worse than not
modelling columns nothing displays.

Also adds the browser-level coverage this plan was missing. The
component suite mounts the strip directly, so it passes even if the
ItemDetail mount is deleted or mis-wired; e2e/item-attachment-strip.spec.ts
pins in a real browser: the strip is mounted and shows only the current
item across an A->B switch, a dropped file appears with no refetch,
delete removes the tile and degrades the inline image to the missing
placeholder, the delete control genuinely takes keyboard focus (jsdom
applies no scoped CSS, so a regression to visibility:hidden is invisible
there), and a peeking master shows tiles with NO delete control. That
last one was mutation-verified: passing canEdit instead of
mutationsEnabled fails it.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 14:00:17 +00:00
xarmian e115bb255e feat(web): delete attachments from the item strip (TASK-2384)
Adds the first in-item delete path for an attachment (PLAN-2382 phase 2).
Before this the only surface was Settings > Storage, which is
workspace-wide and disconnected from the item you're looking at.

Server: handleDeleteWorkspaceAttachment no longer opens with a flat
requireMinRole("editor"). That gate contradicted the UI's grant-aware
canEdit (permissions.ts::canEditItem), which is true for a viewer holding
an item- or collection-level edit grant -- so that user saw the affordance
and got a 403, even though upload already admits them (BUG-1661).
Authorization is now per-attachment, mirroring the upload handler:

  - item-bound: requireItemVisible THEN requireEditPermission. The order
    is load-bearing -- an attachment on an item the caller can't see must
    keep returning 404, not the 403 that would confirm it exists.
  - orphans: unchanged flat editor-role gate plus the guest filter, since
    there's no item context to authorize against.

UI: per-tile delete control, in the DOM unconditionally so it's keyboard
reachable (CSS reveals it on hover/focus-within). Gated on ItemDetail's
mutationsEnabled, not raw canEdit, so a peeking master stays a complete
read-only freeze. Optimistic removal with rollback + toast on failure,
fenced so a switch mid-delete can't resurrect A's tile under B.

The confirm warns when the id is referenced in this item's body, and
deliberately hedges otherwise -- comment bodies, other items' content and
fields JSON are not visible client-side, so it says "may still be
referenced" rather than claiming non-use.

Editor: the attachment-image NodeView assigned img.src with no error
path, so a delete left the browser's broken-image glyph until reload --
reading as a network blip for what is a permanent state. It now degrades
to the same .attachment-missing placeholder the markdown renderer uses,
re-armed on uuid swap so rotate/crop clears a stale placeholder.

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 12:57:37 +00:00
xarmian bdb6f6e12a feat(web): add item attachment strip below properties (TASK-2383)
Surfaces an item's attachments as a compact, read-only icon row between
the Properties panel and the editor (PLAN-2382 phase 1).

- Extract categoryIcon / isImage / formatBytes out of StorageTab into
  $lib/attachments/display so the strip shares one mime table.
- Add the item_id filter to AttachmentListFilters + api.attachments.list
  (server already supports it; no Go change).
- New ItemAttachmentStrip.svelte: fetch bounded at 50, +N derived from
  fetched rows not the response total, renders nothing when empty,
  images open the existing Lightbox, other types download.
- Mounted OUTSIDE ItemDetail's {#key itemSlug}, so the fetch is fenced
  on a load generation + item id (PLAN-2105 / TASK-2112 bug class).

Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
2026-08-01 02:27:34 +00:00
xarmian e97a13eb73 fix(web): untrack the copy dialog's open/close reset (BUG-2379)
Reopening the copy dialog after changing the destination workspace
wedged Svelte's effect scheduler. The dialog silently failed to appear
and every other control on the item pane died with it — the ⋯ menu
stopped opening, the split view could not be closed, the selected item
could not be changed. No console error, because a production build
reports none.

`$effect.pre` called `resetForOpen()` inside its tracked scope.
`resetForOpen` writes `destWs = sourceWsSlug` and then reads `destWs`
back to start the collection load, so the effect depended on a value it
had just written: the write invalidated the effect performing it, the
flush aborted, and the aborted flush stranded unrelated reactivity
across the pane. That is the CONVE-1688 hazard, and the comment
directly above the effect asserted the opposite — that `open` was its
only dependency.

It could not bite on the first open. `destWs` already equals
`sourceWsSlug` there, so the reset is a no-op write and nothing
invalidates. It needs a real destination change, a close, and a reopen.

Both branches now run inside `untrack`, so `open` really is the only
dependency.

Why the review missed it: all ten e2e cases opened the dialog exactly
once. Thirteen plan-review rounds, per-task Codex loops and four
full-diff rounds all reasoned about the effect from its comment, which
claimed the property that was untrue. Adds the reopen case, which
asserts the pane is still alive afterwards rather than only that the
dialog returned — mutation-verified: it fails with the untrack removed.
2026-08-01 01:11:26 +00:00
xarmian 83e5958161 fix(web): classify three more guaranteed pre-write refusals (TASK-2355)
Final review round 2. PRE_WRITE_CODES whitelisted only the copy
handler's own business refusals, so csrf_error, email_not_verified and
a structured internal_error fell through to the outcome-unknown
fallback — telling the user their copy may have committed, sending
them to inspect the destination, and forbidding a retry that is in
fact safe. That is the inverse of the DR-13 hazard and just as wrong:
it sends someone hunting for an item that was never created.

All three are provably pre-write on this route:

  - csrf_error and email_not_verified are rejected by the middleware
    stack before handleCopyItem runs at all.
  - internal_error is emitted here only by resolveAuthorizedCopy
    (handlers_items_copy_resolve.go:128,184), both before the store
    call. A post-commit panic deliberately does NOT emit it —
    afterCopyCommit logs and lets the response stand — and chi's
    Recoverer returns a bodiless 500, which carries no code and so
    still lands in outcome-unknown, which is correct for it.

The ambiguous fallback is unchanged and still catches copy_failed, an
unstructured non-JSON response, a rejected fetch, a timeout, and any
code this list does not name.
2026-07-31 20:09:25 +00:00
xarmian 33598bcc5f fix(web): supersede an in-progress confirm when overrides change (TASK-2355)
The final full-diff review caught a stale-dispatch race. handleConfirm
captures the request up front, then awaits a collab flush and a final
preflight. Override controls stayed interactive across that window and
handleOverrideChange did not advance any generation the confirm was
fenced against, so an edit landing mid-flight left superseded() false
and dispatched the PRE-EDIT values — the user watching their new value
on screen while the old one was copied. On the move path that commits
wrong data with no retry available (DR-13).

Two parts, because either alone is incomplete:

  - overrideGen, bumped on every override edit and checked by
    superseded(). Deliberately NOT previewGen: that one cancels
    in-flight preflights, which an override edit must not do — the
    debounce and single-flight runner already collapse rapid edits.
  - the needs-a-value controls are now read-only while preparing, not
    only while submitting, so the edit cannot be started in the first
    place.

Per final review.
2026-07-31 20:04:43 +00:00
xarmian fbfbfcfe34 test(web): e2e coverage for the copy/move dialog (TASK-2355) 2026-07-31 19:53:16 +00:00
xarmian 8aa87f2f4c feat(web): render the archived-source provenance banner (TASK-2355) 2026-07-31 19:53:16 +00:00
xarmian 5d327c96d1 feat(web): add the cross-workspace copy dialog (TASK-2355) 2026-07-31 19:53:16 +00:00
xarmian bbb21ef23d feat(web): add copy/preflight API client methods (TASK-2355) 2026-07-31 17:49:42 +00:00
xarmian cfc83e8c57 fix(server): report partial and legacy relationships in the copy dry-run (TASK-2369)
Two ways the cross-workspace copy preflight told a user "nothing to lose"
when there was, both violations of PLAN-2357 DR-17's "none of this may be
silent".

P1 — the five relationship counters are ACL-filtered by the caller's
collection visibility (correct, and TASK-2364 chose it deliberately), but
"none" and "none that you can see" rendered identically. A caller with
edit rights on the source and none on its relatives could read
`children_orphaned: false` and run a MOVE believing nothing was stranded,
while hidden children were orphaned in place.

The filtering stays; the uncertainty is now surfaced. Every point that
drops a relationship for visibility reasons sets a new
`warnings.relationships_partial` boolean. It is a BARE BOOLEAN by design:
how many are hidden, of what type and in which collection are exactly the
facts the filter exists to withhold, and a marker that varied with the
hidden count would reinstate the leak DR-10a, DR-10b and the moved-to
pointer each closed separately. A negative test asserts byte equality of
the whole warnings block across two workspaces that differ only in how
much is hidden. It is false for an unrestricted caller AND for a
restricted caller with nothing hidden, so the common case renders exactly
as it did before.

P2 — a child reachable only by a lone legacy `plan` edge was invisible to
GetChildItems (its join is restricted to store.ChildLinkTypes), so an
incoming `plan` relationship reported `child_count: 0` /
`children_orphaned: false` even though archiving the source strands it.
The link scan now folds such an edge into the child set, deduplicated
against the two mechanisms already covered and subject to the same
visibility, liveness and workspace guards. The outgoing direction (the
item's own parent) already reported correctly.

The mutating copy reports no relationship counters at all
(ItemCopyResultWarnings is deliberately narrower), so there is nothing for
assertPreflightMatchesCopy to disagree about.

CLI renders the qualifier on the five affected lines plus a plain-language
explanation; TS types carry the field for Phase 3's dialog.
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 04:26:55 +00:00
xarmian f8ff5742e5 feat(server): add cross-workspace copy endpoint with post-commit fanout (TASK-2365)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 00:59:25 +00:00
xarmian 01d640978c feat(server): add cross-workspace copy dry-run preflight endpoint (TASK-2364)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 20:15:00 +00:00
xarmian 1eb1c9eda6 feat(server): expose ACL-gated moved-to pointer on item GET (TASK-2359)
An item MOVED to another workspace — copied, then archived — can now say
where it went. GET on a single item gains an optional `moved_to` block
naming each destination in displayable terms (workspace slug + item ref +
title + collection slug), so a consumer can render a link without a second
call. No HTTP redirect, no resolver change.

The ACL gate is the point. A destination is revealed only after the caller
independently passes AuthorizeCrossWorkspaceRead (TASK-2358) with an ITEM
scope on the destination item itself. Workspace-level access is not
sufficient: a restricted member of the destination workspace, or a guest
holding one unrelated item grant there, has a role in that workspace while
having no right to the copied item's collection.

A caller who fails that check sees NO hint a destination exists. The key is
omitted entirely — not a null, not an empty array, not a boolean — so the
response is byte-identical to an archived item with no move record at all.
A structurally distinguishable response is itself the leak.

Restore decision: the block is OMITTED for a non-archived source. Restoring
a moved-out source leaves two live items with the same content in two
workspaces, which is legitimate, but at that instant the source has not
moved anywhere and the response must stop asserting that it did. Past-tense
provenance is the back-pointer question and applies equally to plain copies,
which this field must never claim as moves.

Also honored: DR-2a (only archived_source rows feed the pointer; plain
copies are back-pointer material only), per-destination filtering over the
forward lookup's SET with no short-circuit on the first hit or first denial,
newest-first ordering, a scan bound on the per-GET authorization cost, and
deliberate isolation of the hand-rolled public share-link DTO — pinned by an
explicit negative test that freezes its key set.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 12:20:38 +00:00
xarmian fd7c77c665 fix(web): collapse the mobile "Live" badge into the action bar (IDEA-2297) (#1038)
* fix(web): collapse the mobile "Live" badge into the action bar (IDEA-2297)

At <=768px the collection page hid the h1 (the name lives in
MobileContextBar) but left the SSE status badge in .title-group, so that
row rendered a full line containing nothing but "* Live". The badge now
has a mobile mount at the trailing edge of .header-actions, collapsed to
the coloured dot alone, and .title-group drops out of layout entirely --
a 0-height flex item still collected .title-row's 12px column gap, which
was the last of the wasted row. Desktop is unchanged: the labelled badge
stays beside the title.

Compact mode CLIPS the label rather than removing it. The span is a
role="status" live region and live regions announce on text-content
change, so an aria-label-only element wouldn't reliably announce a drop
to "Offline".

Also gives the action bar one control height (IDEA-2297's second half).
The row mixed four: the quick-actions trigger ~22px, New ~24px (no
border), the view dropdown ~26px, icon buttons 28px. All are 28px now.
The trigger is normalised in the page rather than in QuickActionsMenu
because ItemDetail's .meta-actions band sizes the same trigger to its own
padding-based metrics; a height baked into the shared component would
fight it. Same override shape and specificity reasoning that band already
documents -- the child's scoped .trigger-btn.svelte-<hash> is (0,2,0), so
a bare :global(.trigger-btn) would tie and be settled by cross-file
source order.

Mobile gaps go 12px -> 4px. The six controls total ~255px, so the dot's
12px inset didn't fit on one line at 360px (a common Android width) and
wrapped, re-creating the row this change removes. Only the gaps shrink;
the controls stay 28px, so touch targets are untouched.

Verified in the browser against the installed binary: single row with a
12px inset at 430/390/375/360 (wraps at 340, as before); desktop badge
still in .title-group with its label visible and
aria-label="Live updates: Live" intact on both breakpoints; no
horizontal overflow. npm run check clean, 490 web unit tests pass.

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

* fix(web): don't convey mobile SSE state by colour alone (IDEA-2297)

Codex review of #1038: with the label clipped in compact mode, hue was
the only thing separating Live from Offline -- colour-alone conveyance
(WCAG 1.4.1), and red/green is the exact pair dichromatic vision
collapses.

Healthy is now a FILLED dot and every unhealthy state is a hollow ring,
so the distinction that matters ("is the stream up?") is carried by
shape. Reconnecting stays separated from Offline by its pulse, and by
hue for anyone running reduced-motion. Scoped to compact mode -- the
labelled desktop variant already names the state in words.

Verified by forcing each status class onto the live badge and reading
computed styles at 390px: connected is a filled green 8px dot
(border-width 0), reconnecting/disconnected/unauthorized are transparent
with a 2px currentColor ring in their own hue, all 8px.

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

* fix(web): separate reconnecting from offline without motion (IDEA-2297)

Codex re-review of #1038: the hollow ring told Live apart from the
unhealthy states, but Reconnecting leaned on its pulse to separate itself
from Offline -- and the pulse is switched off under
prefers-reduced-motion, leaving amber-vs-red as the only difference for
those users.

Reconnecting now takes a dashed ring. Three states, three shapes --
filled, dashed ring, solid ring -- independent of both hue and motion.

Verified at 390px by forcing each status class and reading computed
styles under both prefers-reduced-motion settings: connected filled
(border-width 0), reconnecting transparent + 2px dashed, disconnected
transparent + 2px solid; the pulse animation resolves to none under
reduce while the dashed ring persists.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-26 22:21:01 -04:00
xarmian a6b48c75b9 fix(web): lane inline-create opens the split pane on desktop, stays put on mobile (IDEA-2298) (#1036)
The board lane `+` opens a Trello-style draft card (TASK-1676) whose Enter
handler hardcoded a full-page `goto(.../{item}?new=1)`. TASK-1676 predates the
split pane (PLAN-2105), so nothing revisited that destination, leaving the one
create gesture that already knows its title as the only card-open path that
bypasses the pane:

- Clicking an EXISTING card opens the split pane (`onItemOpen` → `?item=`);
  creating one navigated the whole page away from the board.
- `?new=1` exists to drop you into the title editor of a fresh "Untitled" item
  (`createNewItem`). On this path the title was just typed, so it re-opened the
  title editor with that title select-alled.
- There was no viewport branching at all, so mobile — where the lane `+` is
  fully present — got ejected off the board too, making a second add a Back
  navigation.

`quickCreateInColumn`'s third param becomes `reveal` (caller INTENT) rather than
`navigate` (a destination): the local-index upsert is now unconditional so the
card always lands in its lane, and revealing means `openItemPane(item)` on
desktop and nothing on mobile. The page owns what revealing means, so BoardView
stays unaware the pane exists. The composer closes on submit on every viewport —
on desktop the pane takes focus, so keeping it open for rapid entry would fight
it. Revisit if feedback asks for mobile rapid-add.

The nav-guard's Save-all keeps passing `reveal: false`; saving drafts on the way
out must never open anything.

New e2e pins both destinations and, on both viewports, that the pathname never
changes and `?new=1` is never set. Two traps worth recording: the created card
renders off the SYNCHRONOUS local-index upsert, so it is not a sync point for
the navigation that follows — the first draft of both tests passed against the
reverted fix because the URL assertions raced an unresolved `goto`. Desktop now
waits on `?item=`; mobile can't poll an absence, so it proves the negative
positively by re-opening the lane composer (only possible if the board is still
mounted, and awaiting it gives a would-be navigation time to land). Verified by
mutating the fix back out: both fail for the right reasons, pass on the fix.

Gates: npm run check 0 errors, npm run test 490 passed, new spec 2/2, pane e2e
64/65 (the one failure is the pre-existing BUG-2334 SSE-toast flake, confirmed
by screenshot and passing in isolation). Codex review CLEAN (CONVE-735).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-26 19:04:50 -04:00
xarmian 74813fcc72 revert(web): restore the pre-TASK-2328 item action bar, then make it fit (PLAN-2326 overturned) (#1035)
* Revert "feat(web): dissolve the item action bar into a new .tab-strip wrapper (TASK-2328) (#1033)"

This reverts commit 10a5ae2271.

* fix(web): action bar holds one row and compresses to the container width

The band was `flex-wrap: wrap` with a hard `min-width: 70px` per button, so
five controls (star + quick actions + children + backlinks + overflow) needed
~340px and wrapped to a second row in any pane narrower than that.

Replace the hard floor with `flex: 0 1 70px` scoped to `.meta-actions`: the
70px basis reproduces the old width when there is room, so nothing moves on a
wide container, and `min-width: auto` bounds the shrink at each button's own
label rather than clipping it. The graph drawer's `.action-btn`s keep the plain
floor — their labels are wider than 70px.

`.menu-anchor` and `.quick-actions-menu` become flex so the ⋯ /  triggers they
wrap participate in the compression instead of sitting at block min-content.

Below a 340px band a container query reclaims 4px of inline padding per side,
which covers the 312px pane minimum (the draggable floor) with the full control
set — measured 0 overflow there, and 0 with a 3-digit child count. Deliberately
not `overflow-x: auto`: an invisible scrollport is what made controls silently
unreachable in TASK-2328.

Also narrows the button `transition: all 0.1s` to the three hover properties.
Now that width is container-derived, `all` animated padding during a pane drag.

Measured in Chromium at 264-912px band widths: no wrap and no clipped label at
any width; anchored ⋯ menu still escapes the new container (panel renders 162px
below the band); mobile BottomSheet still resolves against the viewport
(390x844) rather than the container, for both the ⋯ and  menus.

Gates: svelte-check 0 errors, 490 vitest, 39 e2e across the five specs that
drive these controls.

* fix(web): one width and one height for every action-bar control

The  quick-actions trigger belongs to QuickActionsMenu and never carried
`.action-btn`, so it rendered 41x22 beside its neighbours' 70x26 — a different
width AND height, which is what read as awkward. Give its wrapper the same
70px basis, let the trigger fill it, and set the band's box metrics in one
place instead of two.

The ⋯ overflow trigger is the deliberate exception and now sizes to its glyph
(38px). That needs `min-width: auto` as well as the flex change: `.action-btn`'s
base `min-width: 70px` reaches it as a grandchild, so the direct-child override
missed it and a 70px floor held it wide regardless of flex-basis.

Pin `line-height: 1.35` so glyph metrics stop leaking into the height — "⋯" and
"☆" resolved 1px apart, which `align-items: center` then showed as a misaligned
row — and take block padding to `--space-2` for the requested ~30% more height:
26.1px -> 34.1px (+30.7%).

Measured at 216-864px band widths: one height (34.1px) everywhere, no wrap and
no clipped label at any width, ⋯ exempt at 38px. Uniform width holds wherever
the row has slack; below ~382px the controls necessarily diverge as each
compresses toward its own label, and a label wider than 70px (a 3-digit child
count) still grows past the basis rather than truncating.

Gates: svelte-check 0 errors, 490 vitest, 39 e2e.

* fix(web): harden the  wrapper selector + pin the sheet-containment invariant

Codex review findings on 92f8a6e2 / 5bd799aa.

P2 (real): `.meta-actions :global(.quick-actions-menu)` was (0,2,0), exactly
tying QuickActionsMenu's own scoped `.quick-actions-menu.svelte-<hash>`
`display: inline-block`. Cross-file stylesheet order was the only thing making
`display: flex` win, so a chunking change could silently restore inline-block:
the wrapper would keep the 70px basis while the  inside snapped back to
intrinsic width, undoing the uniform width and shrinking the touch target. The
`div` qualifier takes it to (0,2,1) and wins outright.

P1 (refuted, then pinned): Codex read the Containment spec to mean
`container-type: inline-size` establishes a fixed-position containing block, so
the mobile BottomSheet — a non-portaled `position: fixed` descendant of the band
— would collapse into a ~342x34 strip. Measured in Chromium it does not: the
overlay is confirmed a DOM descendant of `.meta-actions[container-type:
inline-size]` and still resolves to the full 390x844 viewport, for both the ⋯
and  menus.

Since that rests on engine behaviour rather than a guarantee, add e2e coverage
instead of just asserting it. The new spec checks the premise (band really is a
query container, and much smaller than the viewport) before the invariant, and
fails loudly rather than vacuously if BottomSheet ever starts portaling.
Mutation-tested: adding `contain: layout` to the band collapses the overlay to
the band's width and the test fails with "overlay spans the viewport width"
(expected 412, received 364) — which also demonstrates `contain: layout` and
`container-type: inline-size` are NOT equivalent here.

The same spec pins the uniform width/height and the no-wrap, no-clip invariants
on desktop. jsdom computes no layout, so none of this is unit-testable.

* docs(web): correct the containment claim; cover both menus in the sheet test

Codex nit, and it changes the mechanism rather than just the wording.
`container-type: inline-size` applies STYLE and INLINE-SIZE containment, not
layout containment (css-conditional-5 §container-type). Layout containment is
what establishes a fixed-position containing block, so the mobile sheet is safe
BY SPEC, not by engine luck — my comment and the spec header both repeated
PLAN-2326 DR-3's claim that `inline-size` implies `contain: layout style
inline-size`, which is wrong, and wrong in the direction that makes an unsafe
change look safe. Codex reached its P1 from the same bad premise.

Reframed accordingly: the standing hazard is not a future engine, it's someone
adding `contain: layout` (or a transform/filter) to this band later. Both
comments now say that explicitly.

The sheet test also only drove the ⋯ menu while the commit message claimed both.
It now loops over ⋯ and  — separate wrappers with separate styling, so one
does not establish the other — and throws rather than skipping if the  trigger
is missing on an owner-viewed item.

* fix(web): put the action-bar control height back to 26.1px

The ~30% taller controls (34.1px, --space-2 block padding) were rejected on
review — desktop first, then mobile too. Back to --space-1 and the band's
original 26.1px on every surface, so no per-breakpoint split is needed.

The uniform sizing from 5bd799aa stays: all four controls are one height rather
than the 26/22/25 they were before, and the  trigger still matches its
neighbours instead of sitting 4px short.
2026-07-26 10:09:10 -04:00
xarmian 10a5ae2271 feat(web): dissolve the item action bar into a new .tab-strip wrapper (TASK-2328) (#1033)
Task 2 of PLAN-2326 (DR-4, DR-9) — the core of IDEA-2299. The `.meta-actions`
band is gone; its five controls are right-aligned into the tab row.

`.tab-strip` (flex, align-items:center) wraps the UNCHANGED `.pane-tabs`
tablist plus a new `.strip-actions` sibling holding the star, QuickActionsMenu
(its `{#key itemSlug}` wrapper intact), both jump badges, and the `.menu-anchor`
wrapper — moved whole, since it is the `position: relative` containing block the
anchored Menu positions against.

`.strip-actions` is a SIBLING of `.pane-tabs`, never a child: `role="tablist"`
is on `.pane-tabs` itself, so nesting the actions inside would put non-tab
children in a tablist and in range of the arrow-key handler's
`querySelectorAll('[role="tab"]')` walk (DR-4).

DR-9 width allocation: the actions never shrink (`flex: 0 0 auto`); the tab list
scrolls (`min-width: 0; overflow-x: auto`) rather than wrapping or crushing them.
The scroll rule is on `.pane-tabs` ONLY — an `overflow` value on the shared
`.tab-strip` ancestor would clip both anchored popovers. For the same reason the
wrapper carries no `contain` / `clip-path` / `transform` / `filter` /
`will-change`. `container-type: inline-size` is safe (layout/style/inline-size
containment, no paint containment) and is what TASK-2329's tier rule queries;
verified in Chromium that neither the anchored panels nor the mobile
BottomSheet's `position: fixed` overlay are affected.

Both badges split their single text node into `.badge-icon` + `.badge-count`
(DR-9) so TASK-2329 can hide the icon and keep the count. `title` / `aria-label`
and the literal space between the spans are preserved, so the computed
accessible names are byte-identical.

Also here:
- `.pane-tabs` gains `padding-bottom: 1px; margin-bottom: -1px`. `overflow-x:
  auto` computes `overflow-y` to `auto`, which would otherwise clip
  `.pane-tab`'s `margin-bottom: -1px` and leave the active tab a 1px accent on
  1px of divider instead of a solid 2px underline (measured, then re-measured
  after the fix: pixel-identical to before).
- The divider moves from `.pane-tabs` to `.tab-strip` so it spans the full strip
  rather than stopping where the tabs end.
- `.action-btn`'s `min-width: 70px` is overridden under `.strip-actions` only —
  the base rule stays for the graph-drawer controls.
- Explicit print hide for `.tab-strip` / `.strip-actions`; the old rule targeted
  `.pane-tabs` and `.meta-actions` by name, and the new wrapper inherits neither.

Header stack: 222.8px -> 180.8px on the full page at 1440px (-42px), measured on
the same item and viewport across both builds.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-26 01:11:44 -04:00
xarmian 53dc0b7db8 fix(web): delete confirmation becomes an in-menu sub-view (TASK-2327) (#1032)
* fix(web): delete confirmation becomes an in-menu sub-view (TASK-2327)

Moves the inline `.delete-confirm` band out of `.meta-actions` and into
the pane `⋯` overflow as a third drill-down view alongside `move`
(PLAN-2326 DR-6). The band was a ~180px text-plus-two-buttons control
that could not survive the 360px pane the strip refactor (TASK-2328)
targets; as a menu sub-view it is width-independent by construction and
`sheetOnMobile` gives mobile a bottom sheet for free.

Ships first so main never carries a broken intermediate: the strip
refactor deletes `.meta-actions`, and until the confirmation moves, a
`Delete…` click would arm state with no confirmation UI rendered.

- `paneMenuView` widened to `'root' | 'move' | 'delete'`; the `{:else}`
  branch that rendered the move-target list for EVERY non-root view is
  split into explicit `move` / `delete` branches.
- `Delete…` drills down instead of closing the menu; `confirmDelete`
  state is gone. Cancel returns to root, the view resets on close (the
  existing `onclose`), and the item-switch / peek-freeze resets already
  covered `paneMenuView`, so the armed-confirmation-survives-a-switch
  hazard is unchanged. `handleDelete`'s failure path disarms, dismisses
  the menu and returns focus to the trigger.
- Cancel is listed FIRST so the focus handoff lands on the
  non-destructive row — Enter on arrival can never delete. The prompt is
  a presentational div, so Menu's `[role^="menuitem"]` arrow-key walk
  sees exactly the two actionable rows; MenuItem gains an optional
  `describedBy` so the destructive row carries the prompt as its
  aria-describedby (it would otherwise never be announced — Codex P2).

Also fixes the focus-handoff defect that the `move` sub-view already had
(DR-8, folded in per the fold-in-by-default rule): the focus $effect only
ran when `open` changed, so an in-place view swap stranded keyboard focus
on the unmounted MenuItem. `Menu` gains an optional `focusKey` prop that
the effect reads purely for dependency tracking, and forwards it to
`BottomSheet`, which owns focus in `sheetOnMobile` mode and had the same
gap (Codex P1). Both effects still only perform DOM focus/placement, so
neither can self-trigger (CONVE-1688). `ItemDetail` passes
`focusKey={paneMenuView}`, fixing move and delete together on both
surfaces.

Gates: `npm run check` 0 errors; `make check` exit 0; full Playwright
e2e suite green at CI worker count (77 passed). Verified by hand against
`make install` (40 scripted browser checks): in-place swap, cancel,
Escape-closes-and-returns-focus, reset-on-close, arrow-key walk inside
the sub-view, keyboard-only path, focus handoff on BOTH move and delete,
aria-describedby wiring, and an end-to-end delete (`deleted_at` set) —
across full-page, docked pane, mobile bottom sheet, and dark theme.

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

* test(web): FreezeProbe mirrors the ⋯-menu route to delete/move (TASK-2327)

`FreezeProbe.svelte` is a hand-written mirror of ItemDetail's freeze /
permission gate expressions (BUG-2263). Its `delete-btn` and `move-btn`
rendered bar buttons, which no longer exist: #1029 moved Move into the ⋯
overflow and TASK-2327 moved Delete's confirmation there as a drill-down.
The probe stayed green while mirroring markup that was gone — `move-btn`
had been stale that way since #1029.

The row gate (`{#if canEdit}`) was in fact still correct; what was
missing was the REACHABILITY half. Both surfaces are now reached through
one trigger, so the probe mirrors it: `pane-more-btn`, with no canEdit
and no peeking gate (it renders on the peeking side and a click activates
that side first) and `disabled={moving}`. Without it, gating the trigger
on `!peeking` would take delete AND move off the passive side with every
existing assertion still passing.

Delete's confirm row gets its own model and test, because its gate is
genuinely different in two ways:

- It is NOT canEdit-gated. It renders whenever the 'delete' sub-view is
  active and refuses via `disabled={deleting || !canEdit}`, so a
  mid-confirm permission loss leaves it present but inert. (A first draft
  wrapped it in `{#if canEdit}` — caught by Codex, since that would have
  claimed the row vanishes when the real one does not.)
- It IS the one delete-related surface the freeze touches, and in the
  opposite direction to everything else in the file: peek-begin
  force-disarms it (ItemDetail's peek handler resets paneMenuOpen /
  paneMenuView), so an armed confirmation can never survive into a peek.
  The affordance itself stays live on the peeking side as before.

Mutation-tested — all four bite, each failing exactly one test:
peek-no-longer-disarms, confirm-drops-the-permission-guard,
canEdit-gate-the-confirm (the Codex finding), trigger-drops-its-in-flight
guard.

`make check` exit 0 (490 vitest tests, was 488); `npm run check` 0 errors.

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

* fix(web): drop the probe's invented peek gate; mark the menu prompt presentational (TASK-2327)

Two review findings on PR #1032.

1. FreezeProbe gated the delete-confirm row on `deleteViewArmed && !peeking`.
   That reintroduced the drift it was meant to fix, in a subtler form: the
   real row renders on `paneMenuView === 'delete'` ALONE. Peek safety is an
   EMERGENT effect of ItemDetail's peek-begin handler resetting paneMenuOpen /
   paneMenuView — it is not a gate on the row. Encoding it as one is worse
   than asserting nothing: delete the reset from ItemDetail and the probe
   stays green off its own hard-coded `!peeking`, mirroring nothing. The
   earlier mutation testing didn't catch this because mutating the PROBE only
   proves the test is sensitive to the probe.

   The gate is dropped and the render condition mirrored exactly. The
   peek-disarm property is now explicitly NOT claimed, with the reasoning in
   the file: a static prop-driven mirror can't express a transition, and e2e
   can't discriminate it either — every click that causes a peek is also an
   outside-click that closes the menu on its own, so a passing assertion would
   prove nothing. Filed TASK-2337 for real coverage of that reset (it guards
   five other surfaces too — editingTitle / shareDialogOpen /
   editCollectionOpen / showAddLink — and nothing asserts any of them today).

   Re-ran mutation testing on what remains; all four still bite, one test
   each: confirm-drops-the-permission-guard, canEdit-gate-the-confirm,
   trigger-drops-its-in-flight-guard, move-row-drops-its-in-flight-guard.

2. The prompt div inside `role="menu"` was undeclared. It now carries
   `role="presentation"`. Verified against the rendered a11y tree rather than
   assumed: the destructive row reports name "Delete item" / description
   "Delete this item?", Cancel reports no description, and the menu's direct
   children are [presentation, menuitem, separator, menuitem]. A second Codex
   note corrected two overstatements in the comment — role=presentation is not
   what excludes the prompt from the `[role^="menuitem"]` walk (a bare div was
   already excluded), and a menu owns separator/group children too, not only
   menuitems.

Gates: `npm run check` 0 errors; `make check` exit 0; delete flow re-verified
end-to-end (29 desktop + 11 pane/mobile + 7 a11y checks) against `make install`.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-25 22:13:03 -04:00
xarmian c676e08030 fix(web): Phase 5 sweep stragglers — home priority chips, count pill, activity from-value legibility (TASK-2295) (#1031)
The 31-shot both-theme sweep found three real stragglers (25 shots fully
clean; console-billing + connected-apps are cloud-gated routes that can't
render on a self-host box — noted, not bugs):

- Workspace-home Active Work cards: priority was bare colored text — now
  the tinted chip treatment via --chip-c/--chip-alpha/--chip-text-mix.
- Workspace-home header count: plain gray text → the count-pill treatment
  (matches PageHeader).
- Activity page change pills: the 'from' value was near-invisible in dark
  — bumped to --text-secondary.
2026-07-24 21:14:05 -04:00
xarmian 8bc3c5f4c9 fix(e2e): graph tests open the drawer via the pane ⋯ overflow (missed in #1029 — only capstone/host were re-run locally) (#1030) 2026-07-24 21:06:13 -04:00
xarmian 26c3f02136 feat(web): pane action bar consolidates into the ⋯ overflow (TASK-2294 PR B) (#1029)
PLAN-2290 Phase 4, PR B. The pane's action bar becomes the mock's trio —
star, quick actions, ⋯ — with the count-carrying jump badges (🌳 done/total,
📎 N) retained as tab shortcuts:

- Dependency graph / Move to collection… / Share… / Delete… move into a
  pane ⋯ Menu (primitive; BottomSheet on mobile; Move is a drill-down view
  inside the same panel, LaneActionsMenu precedent — replaces the old
  standalone move dropdown/sheet + showMoveMenu state).
- The redundant Timeline text button is removed (the Activity tab IS the
  timeline entry point).
- The Delete… row opens the existing inline confirm strip in the bar;
  handleMove/reset paths repointed to the new menu state.
- Capstone e2e updated: pre-peek opens the ⋯ and asserts the rows; while
  peeking asserts the trigger stays enabled (the BUG-2263 liveness
  guarantee) instead of opening — opening would activate the side.

Gates: svelte-check 0 errors, 488 unit tests, capstone+host e2e 16/16,
⋯ menu runtime-verified (screenshot).
2026-07-24 20:37:33 -04:00
xarmian 059cbcdcf3 fix(web): pane tabs activate on pointerdown (focus-follows click-swallow, CI-caught) (#1028)
* fix(web): pane tabs activate on pointerdown — the focus-follows cascade could swallow the click on a peeking master (CI-caught)

The E2E (Playwright) job caught what fast local runs missed: clicking a
peeking master's tab fires pointerdown (focus-follows flips activePane →
peeking-state re-render cascade) and on slow runners the subsequent click
lands after the churn and is swallowed — activeTab never set, the Details
panel never shows, fill times out. Same same-click detach class as
BUG-2281. Activating on pointerdown (click retained for keyboard) sets the
tab in the same tick as the detector, before any re-render can intervene.

Verified: the two CI-failing specs at --repeat-each=3 locally, 45/45.

* fix(web): pointerdown tab activation is mouse-only (touch scroll-start must not switch panels — Codex)
2026-07-24 20:03:16 -04:00
xarmian d04b714ccb feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294) (#1027)
* feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294)

PLAN-2290 Phase 4, PR A. The mock's tabbed pane, built on the hard rule:
panels are CSS-hidden (.tab-hidden, display:none), NEVER {#if}-unmounted —
the collab editor, ChildItems/ItemTimeline SSE subscriptions, and
BacklinksPanel's count callback all carry mount side effects that must
survive tab switches.

- ItemDetail: pane-tabs tablist after the action bar; Details wraps Code
  Context + .item-body (fields+editor, layout-{layout} preserved);
  Relationships wraps relationships/add/children/backlinks (inside the
  existing {#key itemSlug} block); ONE ItemTimeline instance serves both
  Activity and Versions via the new visibleKinds render-filter. Tabs reset
  to Details on item switch (guarded plain-let effect, no read-write loop).
  Jump buttons switch-tab-then-scroll. Print shows all panels, no tab bar.
  Tab clicks stay interactive while peeking and activate the side per the
  focus-follows-editing model (deliberately NOT an exempt surface).
- ItemTimeline: visibleKinds?: ('comment'|'activity'|'version')[] —
  filter-only over the one merged feed (no refetch on switch); composer
  renders only when comments are visible.
- E2E: five specs updated — tab-click preludes where interactions target
  tabbed sections; four frozen-master tests reworked to assert per-tab
  visuals BEFORE the peek and DOM-based freeze proxies during it (the
  per-surface freeze audit lives in masterFreeze/mutationGate unit suites).

Gates: svelte-check 0 errors, 488 unit tests, the five affected e2e specs
27/27 locally; runtime-verified collab badge synced across a full tab
round-trip, version filter (4 real cards), composer placement, editor DOM
alive throughout.

* fix(web): pane-tabs review fixes — title-Enter surfaces Details before editor focus; ARIA ids/roving-tabindex/arrow nav; block-drag hover integration restored via re-peek

Codex findings on #1027: (1) Enter-after-title-edit now sets
activeTab='details' + tick before focusing the editor (was focusing a
display:none node from other tabs); (2) tablist gains arrow-key roving
focus, per-instance aria-controls/id pairing ($props.id() — two ItemDetail
instances mount on the full-page host), tabindex discipline; (3) host
test 3 regains the end-to-end hover assertion: re-surface master Details
(activates), re-peek via the pane, hover the frozen editor, assert the
handle stays display:none — the reactive-editable choke verified in
integration again, not just by contenteditable proxy.

* fix(web): pane tabs use automatic activation on arrow nav (Codex — roving tabindex must follow focus; activation is free on display-toggled panels)
2026-07-24 19:30:00 -04:00
xarmian 1747054b10 feat(web): toolbar consolidation — View menu w/ saved views, sort/filter icons, collection ⋯ menu (TASK-2293) (#1025)
PLAN-2290 Phase 3, PR B. The collection-page desktop toolbar collapses from
nine controls to five, per the refresh mock:

- View dropdown (Menu primitive, trigger shows current view): List/Board/
  Table as checked rows + the saved-views set folded in (activate rows,
  hover-revealed delete, 📌 default marker, Make/Remove default,
  'Save current view…'). The saved-views tab bar is retired — TASK-1366
  pin/default semantics carry over unchanged.
- Sort select becomes an icon + Menu (menuitemradio rows; BottomSheet on
  mobile); hidden in table view as before.
- Filters becomes an icon button with the active-dot riding its corner;
  the FilterBar expansion behavior is unchanged.
- Archived toggle, Edit collection, Share collection move into a ⋯ Menu
  (owner-gated rows; BottomSheet on mobile). QuickActions  and + New stay.
- Mobile view chip + sheet unchanged.

29 dead CSS blocks deleted (svelte-check-verified); saved-view delete
button re-revealed on row hover (was tab-hover). Zero e2e coupling: suites
pin views via ?view= URLs, none target toolbar selectors (verified).

Gates: svelte-check 0 errors, 488 tests; both menus runtime-verified via
Playwright interaction.
2026-07-24 17:41:25 -04:00