mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 11:26:34 +00:00
7bb9c4acb144eda96eef4a4e9a8d83abb91e7c09
629 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
3d45418c52 | Merge branch 'task/2424-panel-routing' into feat/attachment-options-panel | ||
|
|
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. |
||
|
|
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
|
||
|
|
01e2bc3b5d | Merge branch 'task/2423-details-panel' into feat/attachment-options-panel | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
2dfdfe2244 | Merge branch 'task/2422-action-descriptors' into feat/attachment-options-panel | ||
|
|
ca3428fa07 | Merge branch 'task/2421-host-token' into feat/attachment-options-panel | ||
|
|
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. |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
3b4331dbe6 |
fix(attachments): reload storage tab on workspace change (TASK-2418)
Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
fbfbfcfe34 | test(web): e2e coverage for the copy/move dialog (TASK-2355) | ||
|
|
8aa87f2f4c | feat(web): render the archived-source provenance banner (TASK-2355) | ||
|
|
5d327c96d1 | feat(web): add the cross-workspace copy dialog (TASK-2355) | ||
|
|
bbb21ef23d | feat(web): add copy/preflight API client methods (TASK-2355) | ||
|
|
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 |
||
|
|
f8ff5742e5 |
feat(server): add cross-workspace copy endpoint with post-commit fanout (TASK-2365)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
01d640978c |
feat(server): add cross-workspace copy dry-run preflight endpoint (TASK-2364)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
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
|
||
|
|
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
|
||
|
|
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. |
||
|
|
8bc3c5f4c9 | fix(e2e): graph tests open the drawer via the pane ⋯ overflow (missed in #1029 — only capstone/host were re-run locally) (#1030) | ||
|
|
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). |
||
|
|
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) |
||
|
|
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)
|
||
|
|
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. |