Commit Graph

621 Commits

Author SHA1 Message Date
xarmian b8909befdf feat(attachments): an options panel for files (TASK-2423)
Tapping a file used to do the most destructive-adjacent thing available:
a strip tile was a bare `<a download>`, so one tap put the file in your
Downloads folder with no way to see what it was first. This is what a tap
opens instead — `AttachmentDetailsPanel`, plus the one host that owns it.

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

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

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

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

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

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

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

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

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

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

Tested through the host (20 jsdom cases): addressing with two hosts mounted,
open-with-partial-then-complete, all three metadata arms, Retry's
invalidate-before-refetch, host-supplied permission for peeked vs master,
the full confirm/cancel/failure delete paths, both warning arms, archive-
closes / restore-revalidates, item switch, and re-targeting in place. Focus
entry and return, background inertness, real placement, the sheet swap and
Enter/Space activation are browser-only and belong to phase 3d.
2026-08-04 02:44:57 +00:00
xarmian ebe531ebaa fix(attachments): make the host address a reader, not written-in options
The addressing fix in the previous commit could not have worked. Tiptap's
`options` is a getter returning a fresh spread on every access
(@tiptap/core 3.22.5, dist/index.cjs:3452), so `ext.options.itemId = next`
mutates a temporary and is discarded — an assignment that looks exactly
like working code.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two outstanding findings fixed alongside:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

CopyItemDialog and markdown/attachments.ts are deliberately untouched.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All three are provably pre-write on this route:

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

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

Two parts, because either alone is incomplete:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit 10a5ae2271.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Codex review findings on 92f8a6e2 / 5bd799aa.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two review findings on PR #1032.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Gates: svelte-check 0 errors, 488 tests; both menus runtime-verified via
Playwright interaction.
2026-07-24 17:41:25 -04:00
xarmian 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)
2026-07-24 17:09:49 -04:00
xarmian 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
2026-07-24 16:27:09 -04:00
xarmian 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).
2026-07-24 16:11:32 -04:00
xarmian 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)
2026-07-24 16:04:56 -04:00
xarmian 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.
2026-07-24 15:04:48 -04:00
xarmian 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.
2026-07-24 14:34:54 -04:00
xarmian 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.
2026-07-24 14:03:53 -04:00
xarmian 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.
2026-07-24 13:48:56 -04:00