mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
b8909befdf46714cbb48ee24b595fef4e0c5f156
621 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
e9e114e96c |
feat(web): TableView + share parity; fix subgrid collapse + hyphenated lane accents (TASK-2293/2208/2213) (#1024)
* feat(web): TableView + public-share parity; fix subgrid collapse and hyphenated lane accents (TASK-2293, TASK-2208, TASK-2213) PLAN-2290 Phase 3, PR A2. Parity: TableView status cells become Chip primitives (click-cycle + per-row pulse preserved; read-only tables get static chips), select-value cells colored via fieldColors, focused row = violet tint + accent bar (.table-row/.focused class names kept for e2e); Public* fork (card/list/table/expansion) gets the card-token skin and chip-style pills through the terminal-aware fieldValueColor, multi-select values render as purple tag pills. TASK-2208 (audit): content-visibility:auto implies layout containment, which disables subgrid per spec — every table row collapsed to a single stacked column in Chromium (internal AND public share). Fixed by making the column template fully extrinsic (minmax+fr, no auto tracks) so rows align identically via grid-template-columns: inherit. Runtime-verified: data rows 72px wrapped (was 243-309px stacks). TASK-2213 (audit): columnAccentClassFor now derives lane accents from the canonical STATUS_COLORS map (normalizes hyphens — the default template ships 'in-progress'), and negative-terminal lanes (cancelled/rejected/ wontfix) no longer read done-green. Gates: svelte-check 0 errors, 488 tests; table runtime-verified both the row geometry and the chip rendering. * fix(web): fence TableView pulse timer with a sequence guard (Codex — same-row double-click cleared the second pulse early) |
||
|
|
f994509289 |
feat(web): card anatomy — Chip status/priority, card tokens, violet ring, lane accents (TASK-2293) (#1023)
* feat(web): card anatomy per the refresh mock — Chip status/priority, card tokens, violet selection ring, lane accents (TASK-2293) PLAN-2290 Phase 3, PR A. ItemCard (shared by Board/List/starred/tags/roles): - Skin: --card-bg/--card-border/--radius-lg/--shadow-card; hover = border-strong (no transform — svelte-dnd-action owns card transforms); .focused becomes the mock's violet ring + glow (e2e asserts the CLASS, which is unchanged). - Anatomy: ref stays top-left; star moves to the right cluster before the kebab (ONE auto margin on the star — competing autos split the gap). - Status/priority render as Chip primitives (tinted pills; status keeps click-cycle + pulse via Chip props; labels Title Case, no more uppercase). - Tags become purple-tinted pills; leading separator before parent chip dropped (chips separate visually). - Dead CSS removed (meta-status family, status-pulse keyframes). Lane accents: columnAccentClassFor (shareView — shared with the public fork by construction) gains col-open for open/new/todo/planned; BoardView + PublicBoardView underline it --status-blue. Default underline unchanged for custom vocabularies. Gates: svelte-check 0 errors, 488 tests; board+pane screenshots verified in both themes. * fix(web): consolidate BoardView lane accents onto shared mapper + AA chip text in light theme Codex findings on #1023: (1) BoardView had its OWN columnCssClass — a fifth parallel status-color-ish map, so only public boards got col-open; it now delegates to shareView.columnAccentClassFor (in-app and public boards can't drift, and custom terminal lanes now read as done in-app too). (2) New --chip-text-mix token (100% dark / 72% light) darkens chip text on light surfaces — all chip colors verified >=5.8:1 on white (computed). * refactor(web): columnAccentClassFor moves to $lib/utils/fieldColors (Codex — dependency direction); shareView re-exports |
||
|
|
01a94d93a8 |
feat(web): Menu/MenuItem primitive — 3 menus migrated, escape-stack + portal + pointerdown dismissal (TASK-2292) (#1022)
* feat(web): Menu/MenuItem primitive — escape-stack ESC, portal mode, pointerdown outside-click (TASK-2292) PLAN-2290 Phase 2, PR 4 (final primitive). New shared machinery: - lib/components/common/Menu.svelte — anchored + portal modes (portal = fixed coords + flip/clamp, escapes card content-visibility containment), instance-scoped POINTERDOWN outside-click (structurally removes the BUG-2281 stopPropagation-on-rows detach workaround), ESC via the shared escapeStack at new priority menu=40 (one ESC closes menu before pane/drawer), roving keyboard nav, focus-in/focus-return, BottomSheet swap at 768px, --bg-raised panel skin. - lib/components/common/MenuItem.svelte — icon/hint/danger/menuitemradio rows. - lib/utils/clickOutside.ts + lib/utils/portalAction.ts — extracted from the hand-copied per-menu versions. - app.css: --bg-raised token (dark = tertiary; light = white). Migrated: ItemActionsMenu (portal mode, entire hand-rolled machinery deleted), QuickActionsMenu (anchored + sheetOnMobile, EmojiPicker exemption via exempt(), BUG-2281 workarounds removed), TopBar user menu (desktop + mobile branches deduped into one snippet; gains aria-haspopup/expanded + keyboard nav it never had). E2E locators updated to accessible-name form. Documented leave-alones: TopBar workspace-overflow menu (it IS a dndzone — conditional mount / focus-steal / pointerdown-close each break drag-reorder; in-file comments), LaneActionsMenu drill-down + WorkspaceSwitcher (Phase 3 / later). Gates: svelte-check 0 errors, 488 tests, make check green; runtime-verified via Playwright: user menu roving nav (ArrowDown x2 -> Admin), ESC closes via stack, kebab portal placement + edge-aware rows. * fix(web): Menu review fixes — form focus hand-off, drag suppression, scroll-close without refocus, resize close Codex findings on #1022: (1) QuickActions create-form now receives focus when it swaps in (the focused MenuItem unmounts on the flip); (2) clickOutside gains suppress() and TopBar's user menu passes isDragging||dragArmed so pill drags can't slam it shut (parity with the old drag guard); (3) portal scroll/resize dismissal calls onclose() directly — no trigger refocus fighting the user's scroll (parity with the old returnFocus=false); (4) resize now also closes portal menus (stale fixed coords). |
||
|
|
d087c7d822 |
feat(web): PageHeader primitive + generic EmptyState — 15 pages adopted (TASK-2292) (#1021)
* feat(web): PageHeader primitive + generic EmptyState; adopt across 15 pages (TASK-2292)
PLAN-2290 Phase 2, PR 3. PageHeader (title/icon/count-pill/description/actions
snippet) replaces 9 per-page header scaffolds; EmptyState gains a generic mode
(icon/title/message/actions) alongside its legacy collection mode, adopted at
19 rogue .empty-state sites. Net -430 lines; dead scoped CSS deleted; two
pre-existing dead selectors and an unkeyed {#each} fixed en route.
Documented leave-alones: breadcrumb header on tags/[tag] (interactive
view-toggle), console section-level h2s (PageHeader is h1 — semantics),
connected-apps empty (inline <a> in copy; message prop is string-only).
Gates: svelte-check 0 errors (warnings 7->6), 488 web tests, make check green;
conventions/starred screenshots verified.
* fix(web): PageHeader rows wrap on narrow screens (Codex finding — restores the responsive behavior the deleted per-page mobile rules provided)
|
||
|
|
6422324edd |
feat(web): Button primitive + dark text-on-fill AA — 95 sites migrated (TASK-2292) (#1020)
* feat(web): Button primitive + dark text-on-fill AA fix; migrate 95 button sites (TASK-2292) PLAN-2290 Phase 2, PR 2. lib/components/common/Button.svelte — variants primary (filled --accent-primary-strong #7c4ff0, the violet band where white text passes AA 4.96:1 while staying >=3:1 vs surface — pays off the PR #1018 deferral) / secondary / ghost / danger (red tint, AA both themes); size sm/md; full attr passthrough (type=submit preserved at form sites). 95 usages across 14 files migrated (settings, conventions, playbooks x2, workspace home, console suite, modals, comment composer, EmptyState); dead scoped .btn* CSS deleted (net -291 lines). Deliberate leave-alones per file: ItemDetail action-bar strip (Phase 4 owns the pane), anchors styled as buttons, segmented controls, icon-only buttons, dashed low-emphasis affordances. Gates: svelte-check 0 errors (dead-selector warnings down 8->7), 488 web tests, make check green; screenshots reviewed both themes. * fix(web): Button class-prop merge + danger-solid variant for final confirms Codex findings on #1020: (1) caller-supplied class no longer clobbers the primitive's classes — class is destructured and merged, rest spread moved first; (2) new danger-solid variant (filled --accent-red-strong #dc2626, white text 4.83:1 AA both themes) restores destructive emphasis on the two final-confirm flows that had gone pale (OpenChildrenDialog override, conventions delete Confirm); entry-level destructive buttons keep the tint. |
||
|
|
a335033415 |
feat(web): Chip primitive + canonical fieldColors util — 48 badge sites migrated (TASK-2292) (#1019)
* feat(web): Chip primitive + canonical fieldColors util; migrate 48 badge sites (TASK-2292) PLAN-2290 Phase 2, PR 1. Extracts the first shared primitives: - lib/utils/fieldColors.ts — ONE statusColor/priorityColor (+ hasCanonicalStatus, formatFieldLabel), replacing four drifted implementations (ItemCard, fields/FieldEditor, CommandPalette, workspace home); shareView.ts re-exports it so public shares stay in lockstep. Deliberate unifications: open/new/todo/ planned -> --status-blue (was text-secondary on cards); active -> green (was cyan in palette/home); draft -> muted (was blue); rejected/cancelled/wontfix -> gray; priority medium -> text-secondary. - lib/components/common/Chip.svelte — tinted-pill primitive per the refresh mock (color-mix tint via new --chip-alpha token, colored text, dot/size/ onclick/pulse props); svelte-autofixer clean. - 48 badge usages across 15 files migrated to Chip; scoped .badge CSS deleted (net -355 lines). Deliberate leave-alones: GraphToolbar count bubble + filter toggles, stat tiles, timeline rail markers, avatars. Gates: svelte-check 0 errors, 488 web tests, make check green; board/settings screenshots verified in both themes. * fix(web): Chip button variant always preventDefaults (never navigates a parent <a>) Codex finding on #1019: an onclick Chip inside a link card would activate the link after the callback. preventDefault always (a chip is never a link); propagation intentionally continues so click-outside closers work — callers in interactive cards stopPropagation per the house pattern. |
||
|
|
841a2cb4ea |
feat(web): violet retheme — accent-primary alias, neutral scale, card tokens, radius, AA text (TASK-2291) (#1018)
* feat(web): violet retheme — accent-primary, neutral scale, card tokens, radius, AA text (TASK-2291) PLAN-2290 Phase 1, PR B. Values-only retheme in app.css + theme-color meta: - --accent-primary #9268f8 dark / #7c3aed light; --accent-blue aliased to it (~95% of its 462 sites are brand usage; categorical sites moved to --status-blue in PR A and stay blue). Dark value chosen by contrast math: AA as link text (4.85:1) while improving white-on-fill from 2.75 to 3.78 (>=3:1 UI threshold; full text-on-fill AA lands with the Phase 2 Button primitive via --text-on-accent). - Violet-biased neutral scale both themes; light mode inverts to off-white canvas (#f5f5f9) with white surfaces per the mock. - Muted/secondary text re-picked: >=5:1 on every bg token in both themes (closes TASK-2262 C9 app-wide). - --border-strong/--card-bg/--card-border/--shadow-card defined both themes (consumed from Phase 3). - Radius scale 6/4/8 -> 8/5/12; light-mode danger tuned to #dc2626 (4.83:1). - theme-color meta #4a9eff -> #8b5cf6. Verified: make check green; screenshots on 4 surfaces x 2 themes reviewed; contrast ratios computed for all text-token pairs. * fix(web): violet PWA branding (manifest/icon) + pin light accents in print block Codex review findings on #1018: manifest theme_color/background and icon.svg still carried the blue brand; print block now pins light-theme accents so dark-theme printing doesn't put the bright violet on white paper. Dark button text-on-fill AA is explicitly deferred to the Phase 2 Button primitive (tracked in TASK-2292). * fix(web): regenerate apple-touch-icon.png from the violet icon.svg (180x180) * fix(web): regenerate remaining brand rasters from violet icon.svg favicon-16/32, favicon.ico (single PNG-encoded 48px entry), icon-192 (was 0 bytes), icon-512, padicon.png (OG image, 701x701) all regenerated from the canonical icon.svg 'P' mark — the old rasters were a blue calendar design inconsistent with the linked SVG. site.webmanifest colors updated to the violet scheme. |
||
|
|
563371ee9b |
feat(web): define missing token families + zero-change drift sweep (TASK-2291) (#1017)
PLAN-2290 Phase 1, PR A. Defines --accent-red, --status-blue, --text-on-accent, --shadow-sm/md/lg, --modal-shadow, --scrim in app.css (values matching the long-standing inline fallbacks), then mechanically sweeps: - var(--accent-red, #hex) fallback forms collapsed (52+4+1 sites); 3 bare var(--accent-red) sites that previously resolved to NOTHING now render - phantom var(--color-danger, #dc2626) repointed to --accent-red - bare #ef4444/#dc2626 danger literals -> var(--accent-red) (57 files); #c0392b/#e53e3e/#dc2626 outliers unify to #ef4444 (deliberate) - shadow fallback forms collapsed to the now-defined tokens - 10 categorical literally-blue sites (status maps, burndown chart, info badge) repointed --accent-blue -> --status-blue so PR B's violet accent flip won't drag status colors Verified: svelte-check 0 errors, 488 web tests, make check green; Playwright before/after pixel diff on 4 surfaces x 2 themes — identical except the sidebar build-id string. |