Commit Graph

1131 Commits

Author SHA1 Message Date
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 d9d96b85c9 refactor(store): delete two unused item-workspace-move accessors (TASK-2374) 2026-07-31 17:28:05 +00:00
xarmian 3bbd326857 test(store): make the copy concurrency and attachment assertions bite (TASK-2372) 2026-07-31 16:50:37 +00:00
xarmian c6ebe5a3e3 refactor(store): unify the collection column list and scan (TASK-2368)
Three accessors read a full collection row and each carried a verbatim
copy of the same 15-column projection and scan/hydration block:
GetCollection, GetCollectionAnyState, and the transactional
getCollectionInWorkspaceTx used by the cross-workspace copy. A column
added to the model had to be added in three places, and the copy path
drifted silently if only GetCollection was updated.

Extract collectionColumns plus scanCollectionRow, parameterized over
rowQueryer (the uniqueSlugQ / validateAssignmentScopeQ pattern from
TASK-2362) so the same read runs against *sql.DB or inside a caller's
*sql.Tx. Each accessor's full statement is assembled from constants, so
the WHERE predicate is the only per-caller difference, the SQL is built
at compile time rather than per call, and no runtime-assembled fragment
is ever handed to s.q.

Preserved deliberately: s.q placeholder rewriting (applied once, inside
the helper, so no call site can skip it); nil-on-sql.ErrNoRows at every
accessor -- the helper returns real errors unwrapped so each keeps its
own distinct prefix; the transactional lookup stays workspace-scoped and
active-only, which is the security boundary that makes a foreign
collection a not-found rather than a cross-workspace write.

lockCollectionRows is untouched: its SELECT id ... FOR UPDATE is a
locking primitive that duplicates nothing, and its sorted acquisition is
load-bearing.

ListCollections is deliberately left out and documented as such: it is
an aggregate multi-row query with aliased columns, a trailing COUNT and
no deleted_at, so sharing a projection would need a second count-aware
scanner and would reshape a hot query for no correctness gain.

TestCollectionAccessorsShareOneHydration pins all three to one
hydration. Every scanned column except deleted_at is asserted against a
literal, distinct value rather than against another accessor's output,
since cross-accessor equality alone cannot catch a mutation in the
shared projection; created_at and updated_at are set to different
instants so transposing them fails, and deleted_at is pinned by the
soft-delete branch, the only state in which it is non-nil. Verified by
mutation: a transposed slug/prefix projection, a transposed
created_at/updated_at projection, a dropped workspace scope on the
transactional read, a flattened deleted-state predicate, and a miss
turned into an error each fail the test.
2026-07-31 16:02:52 +00:00
xarmian 98c638fc86 refactor(server): extract resolveAuthorizedCopy shared by preflight and copy (TASK-2370) 2026-07-31 14:43:40 +00:00
xarmian 70f9fefeee Merge pull request #1048 from PerpetualSoftware/feat/cross-workspace-item-copy
feat: cross-workspace item copy (PLAN-2357)
2026-07-31 09:15:06 -04:00
xarmian c783d36a13 fix(store): make migration 077 constraint-equivalent to 055 per final review
Postgres' BOOLEAN admits exactly two values; SQLite's bare INTEGER admits
any. A stray 2 would scan as true through BoolToInt while the partial
index the moved-to lookup uses is WHERE archived_source = 1 — a row that
reads as a move but is invisible to the query that finds moves, which the
Postgres schema cannot represent. Add the CHECK, and make id NOT NULL
explicit since SQLite does not imply it for a TEXT PRIMARY KEY.

Migration 077 is unreleased, so amending it in place is safe.

The test is mutation-verified. Its first draft was NOT: it used
placeholder ids and passed against a schema with no CHECK at all, because
the foreign keys rejected the insert before the constraint under test was
reached. It now uses real fixture rows and asserts the same row inserts
cleanly with archived_source = 1.

Found by the final full-diff Codex pass over PLAN-2357, data-at-rest angle.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 12:45:58 +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 66fa464699 fix(store): distinguish SQLite lock timeout from a real deadlock per final review
isDeadlockError matched SQLite's "database is locked" alongside
Postgres' 40P01, and the rollback path logged both as deadlock=true at
ERROR. But SQLite is single-writer with a 30-second busy timeout, so
"database is locked" is an expected saturation mode under burst load —
it says the box is busy. A 40P01 says DR-9's lock ordering, which is
meant to make deadlock impossible, is wrong. Reporting both identically
left an operator unable to tell a lock-ordering bug from ordinary load,
defeating the only signal this log exists to carry.

Split the predicates and add lock_timeout to the log line. Classification
test is mutation-verified: reintroducing the conflation fails it.

Found by the final full-diff Codex pass over PLAN-2357, operability angle.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 02:43:56 +00:00
xarmian 2b9da9412b fix(store): classify unique violations as expected copy rejections per final review
The store logged a unique-constraint violation as an unexpected rollback
incident while the HTTP layer mapped the same error to a caller-facing
409. A workspace-unique field colliding in the destination — a playbook's
invocation_slug, say — reaches this on ordinary input, so every routine
409 fired an operator warning and buried the deadlock signal the log
exists to surface.

Found by the final full-diff Codex pass over PLAN-2357 (P2: two
commits classified the same error two ways).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 02:39:20 +00:00
xarmian f15ba86db0 docs(server): correct the cross-workspace authz re-check contract per final review
The helper's doc mandated that a mutating caller "re-apply the check"
inside its write transaction. Its only mutating consumer deliberately
does not, and is right not to: these functions read through s.store
rather than the caller's tx, so under READ COMMITTED the re-check would
judge locked resources against authorization state read at several
unsynchronised moments — reading as a write-time guarantee while
providing none.

State what a mutating caller actually owes (re-read the authorized
resource IDENTITY in-tx and refuse if it moved) and what it must not do,
so the contract and copyResourceInvariantPreCheck no longer disagree.

Found by the final full-diff Codex pass over PLAN-2357 (P1: a documented
write-time guard was in fact a TOCTOU check).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 02:35:53 +00:00
xarmian 1e48a7a1dd feat(cli): add pad item copy for cross-workspace copy and move (TASK-2366)
Wraps PLAN-2357's two endpoints behind one command:

  pad item copy <ref> --to-workspace <slug> --collection <slug>
                      [--dry-run] [--archive-source] [--field key=value ...]

--dry-run renders the preflight's three contract buckets (carried /
dropped / needs_value) and DR-15's full warning set. Every bucket header
and every warning line prints unconditionally, zeros and empties
included: omitting a zero would make "no attachments" indistinguishable
from "this CLI does not report attachments", and DR-17's whole point is
that none of it is silent. Schema-supplied strings are escaped and list
members quoted, so a comma or newline in an option value cannot forge an
entry or a row.

--format json emits the endpoint's own response. json.Indent is a lexical
transform, so key order, unmodelled fields and int64 precision all
survive; the bytes are never round-tripped through a Go value.

DR-13, the no-retry obligation. There is no idempotency key, so a blind
re-run duplicates the item. Four mechanisms, each with a test:

  1. the mutating copy runs on its own *http.Client AND its own
     transport. The transport half is the one that matters: retry in Go
     is almost always a RoundTripper wrapper, which a merely-dedicated
     http.Client would inherit. A plain *http.Transport is cloned so
     proxy/TLS config carries; a wrapper is not used at all;
  2. its body is hidden behind an opaque reader, leaving Request.GetBody
     nil so net/http's own nothing-written replay cannot fire;
  3. redirects are refused rather than followed with the POST body;
  4. failures are classified into three exclusive outcomes, because each
     licenses a different thing to say. UNKNOWN (transport failure, 500
     copy_failed) sends the user to check the destination and never
     suggests a retry. COMMITTED-BUT-UNREPORTED (a 2xx whose body could
     not be read or decoded) exits ZERO -- a non-zero exit would tell a
     script the copy did not happen, which is the DR-13 duplicate
     arrived at through the reporting layer. A 4xx is a refusal made
     before any write and passes through plainly.

The same asymmetry governs stdout: a write failure on the dry run is an
error (nothing happened), while a write failure after the copy committed
goes to stderr and leaves the exit code at 0.

Refuse to guess. The preflight always runs first (it is read-only), and a
non-empty needs_value refuses before any mutating request, naming each
field and the exact --field flags to add. Mirrors the web dialog's
disabled confirm rather than round-tripping the user into an error they
could have been shown.

--field values are typed against the DESTINATION collection's schema, so
a number lands as a number. A malformed --field is a hard error here
rather than the silent skip `pad item create` does: this command's
contract is "you were told what to supply", and dropping a supplied value
would make the refusal a lie.

The response types in internal/cli mirror internal/server's. That is a
layering choice, not a cycle -- nothing in server imports cli, and the
mirror test imports server freely. It follows the posture already
recorded in internal/cli/bootstrap.go: this package is the HTTP client
and does not depend on the server package. An external cli_test package
walks both response shapes and fails on any JSON contract drift.

MCP is deliberately untouched: no pad_item.action: copy, and
ToolSurfaceVersion stays 0.15.
2026-07-31 02:23:24 +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 0fad869a28 feat(store): add CopyItemAcrossWorkspaces atomic orchestration (TASK-2363)
PLAN-2357 DR-9 / DR-9a / DR-11 / DR-12 / DR-14 / DR-16 / DR-17. One
store operation, one transaction: create in B, clone attachments,
archive A on a move, write provenance.

Lock order (the whole point of DR-9):
  1. Both workspaces' advisory locks, sorted and deduplicated by the
     hashtext LOCK KEY — sorting the ID strings does not order their
     hashes, so two opposing movers could still deadlock.
  2. Both collection rows FOR UPDATE, sorted by collection ID —
     MigrateFields consumes both schemas.
  3. Source item re-read under those locks; that snapshot is copied.
Both primitives are dialect-gated: FOR UPDATE is a syntax error on
SQLite, where BEGIN IMMEDIATE already serializes writers.

Pipeline: migrate -> overrides -> validate (DR-12: MigrateFields'
errors are stale once an override lands) -> quota -> PlanAttachmentCopy
INSIDE the tx -> rewrite content AND fields via the plan's IDMap ->
create in B -> attachment rows (originals before variants, item_id set
from the outset, uploaded_by = the actor) -> archive A -> provenance.

Seq (DR-14): B always advances; A advances only on ArchiveSource, and
a plain copy leaves A completely untouched. Quota (DR-16) runs inside
the transaction after the destination lock so two concurrent copies
cannot jointly exceed the cap.

Cross-backend attachment copies are REFUSED in v1
(ErrCopyCrossBackendAttachments): the store has no AttachmentStore
handle, and a byte transfer under both workspaces' locks would block
every writer in both workspaces on unbounded I/O with no rollback.

Supporting changes:
- CreateAttachmentTx: tx-taking insert (CreateAttachment is
  self-committing), sharing one body with the pool form.
- CheckLimitTx: the feature COUNT reads through the caller's tx.
- createItemTxWithID: createItemTx with a caller-supplied id, so the
  destination item id exists before the attachment plan is built.

Tests: creation parity, seq on both sides, DR-12 ordering, DR-8/DR-17
scrubs, attachment clone + rewrite (including refs in code fences),
DR-11a unresolvable refs, rollback at all four stages, quota. Postgres
only: opposing A->B / B->A copies do not deadlock, concurrent copies
cannot jointly exceed the cap, and colliding hashtext keys take one
lock. All three verified falsifiable by mutating the production code.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 15:50:18 +00:00
xarmian 60dd3e1a37 feat(store): add attachment resolution planner for cross-workspace copy (TASK-2354)
Implements PLAN-2357 DR-11 / DR-11a. PlanAttachmentCopy takes the copied
content plus the FINAL destination fields and returns the old->new
attachment UUID map, the rows to create (originals followed by their
variants, parent_id remapped), the byte total, and the unresolvable-ref
list. It writes nothing, takes no *sql.Tx, and is shared by the copy
orchestration and the dry-run endpoint so their numbers cannot drift.

DR-11a: every resolution is scoped to workspace_id = A AND deleted_at IS
NULL, and the parent/variant traversal carries the identical scope. The
reference set comes from user-controlled content, so an unscoped lookup
would let a user clone another workspace's blob into a workspace they
control, bypassing the download handler's workspace check. Refs that
resolve to nothing under that scope -- dangling, soft-deleted, or foreign
-- are never cloned and never fatal: they get no map entry, so the
rewrite preserves the literal text and the copy renders exactly as broken
as the source did.

A cross-backend row emits an empty storage_key with the source key in
SourceStorageKey, so the plan never contains a key the target backend
cannot resolve. CreateAttachment now rejects an empty storage_key, which
turns that contract into an enforced invariant: an orchestration that
skips the Get/Put byte transfer fails at insert rather than creating a
live attachment that 404s on download.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 14:37:51 +00:00
xarmian dbede59edf refactor(store): extract tx-taking item creation helper (TASK-2362)
Implements PLAN-2357 DR-9a. CreateItem opens and commits its own
transaction, so the cross-workspace copy path (create in B + attachment
remap + provenance row + optional source archive, all atomic) cannot
call it. A raw in-tx `INSERT INTO items` in its place would silently
break version history, wiki-links, reporting, delta sync and slug
uniqueness -- none of which fail loudly.

Extracted, not duplicated, and CreateItem now goes through the same
function so the two paths cannot drift:

- `insertItemTx` is the write half, lifted verbatim out of the old
  tryCreateItem body: the items INSERT (item_number, workspace seq,
  content-flush watermarks), the initial item_versions row, wiki-link
  indexing + broken-title resolution, and the create-time
  status_transitions row.
- `createItemTx(tx, workspaceID, collectionID, input) (*models.Item, error)`
  wraps it with the rest of CreateItem's pipeline -- defaults,
  assignment-scope validation, workspace-scoped unique slug allocation --
  inside a caller-owned transaction. It returns the item read back in-tx
  so an orchestrator can consume its slug / item_number / seq (DR-14
  fanout) without a post-COMMIT round-trip.
- `tryCreateItem` is now a BEGIN/COMMIT wrapper around createItemTx, and
  CreateItem is the retry loop around that.
- `uniqueSlug` and `validateAssignmentScope` gained rowQueryer-
  parameterized forms (`uniqueSlugQ` / `validateAssignmentScopeQ`) so
  both can run on the caller's transaction. The *sql.DB entry points
  delegate to them; behaviour is unchanged.

Content must already be final: wiki-link indexing and the first version
row are written from input.Content as given, so callers doing DR-11
attachment-ref rewriting must rewrite BEFORE calling.

Trust boundary is documented on the function. collectionID/workspaceID
consistency and ParentID scope stay the caller's job, matching the
pre-extraction tryCreateItem -- DR-9 has the orchestrator re-read and
row-lock both collections in-tx, so a check here would be a second,
weaker read of an already-pinned row. Assignee and agent role ARE
validated, as in CreateItem. No internal retry on unique violation: a
failed statement poisons the caller's transaction and an internal retry
would need a savepoint the caller can't see.

Fixes a latent slug race in CreateItem along the way. It used to
allocate the slug ONCE, outside the transaction, and re-submit that
stale value on every retry -- so two concurrent creates of the same
title had the loser burn all ten attempts on a slug the winner had
already committed and then fail with a unique-constraint error. Slug
allocation now happens inside the transaction under the workspace
advisory lock, so each attempt sees the previous scan's outcome. Two
Postgres-falsifiable concurrency tests cover it (createItemTx-only and
mixed CreateItem + createItemTx).

24 tests: one per DR-9a parity-checklist line, a rollback test asserting
no item / version / wiki-link / status transition / seq advance
survives, an in-tx-visibility test, a slug-collision test, and the two
concurrency tests. Every parity assertion verified falsifiable by
mutating the production code.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 13:03:16 +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 5804c80146 feat(server): add cross-workspace authorization helper (TASK-2358) 2026-07-30 12:20:38 +00:00
xarmian bf14c1168a feat(store): add item_workspace_moves provenance table (TASK-2356)
Phase 1 of PLAN-2357. Durable record of "this item was copied/moved from
workspace A to workspace B", backing the forward redirect (TASK-2359) and
the destination's back-pointer. Implements DR-2 / DR-2a.

Paired, dual-dialect, forward-only migrations (migrations/077 +
pgmigrations/055). archived_source distinguishes a move from a plain copy
(INTEGER on SQLite, BOOLEAN on Postgres, written through dialect.BoolToInt).
source_seq is a NULLABLE per-source move ordinal that exists solely so two
moves inside the same second are orderable — created_at is second-precision
RFC3339, so archive -> restore -> move again would otherwise resolve to an
arbitrary destination. Partial index (source_item_id, source_seq DESC) WHERE
archived_source, deliberately NOT unique: restore-then-move-again legitimately
repeats. The back direction IS uniquely indexed — a destination item is
created by exactly one copy, in the same transaction that writes its
provenance row, so a duplicate there would silently change which source the
back-pointer names.

Cascade is asymmetric on purpose, inverting item_collection_moves: the
archived source is precisely the row whose pointer must survive, so
source_item_id carries no FK at all; target_item_id cascades, because a
pointer at a vanished destination is worse than no pointer.

Store accessors: a tx-taking insert helper (no self-committing variant — the
row must land in the copy transaction), a forward lookup returning a SET
newest-first, and a back lookup. The insert rejects an archived row with no
seq and a copy row with one, so DR-2a's ordering invariant is enforced at the
write boundary rather than assumed. NULL ordering is normalized with COALESCE
because SQLite and Postgres disagree on DESC NULL placement.

Also wires workspace purge, which the two-workspace shape requires: both
workspace columns are RESTRICT references, so a purge clearing only one
direction would fail outright when the purged workspace sits on the other end.

Tests cover insert-in-tx, forward lookup with multiple destinations ordered
newest-first and scoped to one source, back lookup, rollback leaving no row,
and both DR-2a criteria. The ordering and scoping tests use fixed row IDs
whose lexical order contradicts the expected answer, so deleting the ordering
term or the WHERE clause under test fails them on every run rather than half
the time; verified by mutating the production query.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 12:20:38 +00:00
xarmian cdb495d400 chore(deps): bump google.golang.org/grpc to v1.82.1 for GO-2026-6061 (BUG-2361) (#1050)
grpc v1.79.3 is affected by GO-2026-6061 (xDS RBAC authorization engine
and HTTP/2 transport server). It reaches us indirectly via
internal/oauth -> ory/fosite -> ory/x/otelx -> otlptracehttp -> grpc.

The bump pulls otel v1.42.0 -> v1.43.0 as grpc's required floor, which
re-exposes GO-2026-5158 (uncapped baggage header parsing). Per the otel
lockstep policy, otel/metric/trace/sdk all move together to v1.44.0,
which clears it.

make vuln is now completely clean (0 call-reachable vulnerabilities).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 08:20:17 -04: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 a94f2d37d4 fix(deps): bump otel to v1.42.0 — clears GO-2026-5506 + GO-2026-5158, un-reds main CI (#1026)
* fix(deps): bump go.opentelemetry.io/otel family to v1.42.0 (GO-2026-5506, GO-2026-5158)

CI's Go job has been red on main since GO-2026-5506 published (reachable
baggage/propagation symbols in otel v1.40.0). v1.41.0 fixes it but carries
GO-2026-5158 (fixed in v1.42.0), so bump straight to v1.42.0 (otel +
metric + trace in lockstep; sdk untouched per go mod tidy).

Verified: make vuln (binary-mode govulncheck) exit 0, go build ./..., full
go test sweep green (24 ok).

* fix(deps): bump otel/sdk to v1.42.0 in lockstep with the API (Codex — otel compat policy pairs SDK with API version)
2026-07-24 18:07:40 -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
xarmian 222c596a96 test(e2e): add BLOG-2289 v0.11 pane screenshot capture block (#1016)
Reusable blog-screenshot capture for the Pad v0.11 detail-pane post
(pad-web/static/blog/pad-v0-11-item-pane/01-item-pane.png). Follows the
existing BLOG-1007 / BLOG-1704 pattern; gated on PAD_BLOG_SCREENSHOTS=1
so it never runs in normal CI. Opens the docked pane via ?item=<ref> on
a seeded, content-bearing task, and logs the browser session first so the
pane's collab editor hydrates (WS auth is cookie-based) instead of
capturing a loading skeleton.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-23 16:38:55 -04:00
xarmian faf9b3734a feat(web): default new collections to Board — schema-aware (IDEA-2274, IDEA-2287) (#1015)
* feat(web): default new collections to Board view (IDEA-2274)

Board becomes the baseline default view for new collections; existing
collections keep their stored default_view (no migration).

- Frontend fallback (settingsDefaults, collection-page defaultMode,
  shareView coerce, initial viewMode) -> board
- Create/Edit collection modals default -> board
- Backend template seeds (defaults.go, templates*.go) list -> board for
  ideas/plans/docs/hiring/interviewing collections (tasks was already board)
- CLI `pad collection create` and MCP mapCollectionCreate defaults -> board
- Curated create-modal presets with deliberate list curation (Meeting
  Notes, Decisions, OKRs) intentionally left as list
- Pin the three list-keyboard-nav pane E2E tests to ?view=list

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

* fix(web): board default reaches public share page + ItemDetail fallback (Codex round 1)

Codex review found the public share route (s/[token]) derives its owner
default view via a separate `?? 'list'` fallback that bypassed the
coerceSettings change, so settings-less/legacy collections rendered List
on public share pages. Align it (and the pre-init selectedBase) to board.
Also align ItemDetail's inline CollectionSettings fallback (default_view
is unused there, but keep it consistent with settingsDefaults).

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

* fix(collections): group Contacts board by relationship, not status (Codex round 2)

Contacts has no `status` field, so defaulting it to Board grouped by the
default `status` rendered every card in a single Uncategorized lane. Set
BoardGroupBy=relationship so the board shows real lanes. All other
board-defaulted seed collections have a status field or an explicit
board_group_by (verified: Companies/Conventions/Playbooks/Docs have status).

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

* fix(web): always serialize ?view= so a List URL survives a board default (Codex round 3)

buildCollectionUrlParams treated List as the implicit URL view and omitted
it. With Board now a possible collection default (IDEA-2274), a List
selection on a board-default collection produced a URL that, when copied or
opened without the sender's localStorage, resolved back to Board. Always
serialize the view mode; add a covering unit test. Verified the pane E2E
suite (URL-equality assertions) stays green.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
v0.11.0
2026-07-23 13:33:01 -04:00
xarmian 14f624dd42 feat(web): show abbreviated item age on board & list cards (IDEA-2286) (#1014)
Add an item's age (created_at) to the shared ItemCard, right-justified in
the .card-meta row so it sits opposite the status — visible at a glance on
both Board (compact) and List views. TableView renders its own rows and is
unaffected.

Reuses the shared relativeTime() the item-detail header already uses
("3h ago", "5d ago", then a short date) rather than a bespoke format.
A dedicated .meta-spacer (not margin-left:auto on both assignee and age)
keeps the right cluster deterministic — two competing auto margins would
split the free space and strand the assignee mid-row. Absolute timestamp
on hover via a title tooltip.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-23 10:14:27 -04:00
xarmian 1693a0d264 feat(web): show an Uncategorized lane on the Board for items with no group value (IDEA-2275) (#1013)
The kanban board only bucketed items into the group field's known select
options, silently dropping any item whose value was empty, missing, or a
stale/removed option — those items were invisible on the board and could
only be found in other views.

Add a pinned "Uncategorized" lane (leftmost) that collects every such item,
rendered only when uncategorized items exist. Extract the bucketing into a
pure, unit-tested helper (bucketByColumn) that routes empty/unknown-value
items into an UNCATEGORIZED ('') lane instead of dropping them.

- Lane is pinned leftmost and kept OUT of the persisted, drag-reorderable
  column order (can't be reordered into the middle or written to saved order).
- Droppable like any other lane: dragging a card in sets the group field to
  '' (server-safe clear, reversible); menu-driven horizontal moves work in/out
  of the lane via the render-order adjacency.
- Header drops the drag handle and the "+" add affordance (creating an
  explicitly-uncategorized item makes no sense) but keeps the bulk "⋯" menu
  for triage; dashed muted accent distinguishes it from real status columns.
- Keyboard nav follows the render order so the lane is navigable.

Verified live: Ideas board grouped by impact shows Uncategorized(202) leftmost
with Low/Medium/High, no console errors.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-23 09:38:53 -04:00
xarmian ccf7dafe9e fix(web): inert the collapsed sidebar so off-screen nav leaves the a11y tree (BUG-2282) (#1011)
The mobile sidebar drawer collapses via translateX + pointer-events:none but
stayed in the accessibility tree and tab order, so a screen-reader virtual
cursor and keyboard Tab still reached its off-screen nav links. Bind `inert` to
the same !sidebarOpen condition that drives the collapse class + the existing
pointer-events:none rule, so a collapsed drawer leaves both the a11y tree and
the focus order — covering the mobile drawer and the latent desktop width:0
collapse. The re-open control lives in TopBar (outside the aside) so nothing is
trapped; swipe-to-open is a window handler, unaffected.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 23:57:39 -04:00
xarmian e2ec876be9 fix(web): make itemMatchesRef workspace-aware in ItemDetail (IDEA-2135) (#1008)
The no-{#key} switch-boundary predicate compared only ref/slug identity,
never workspace. On a reused embedded ItemDetail instance, navigating
ws1?item=TASK-1 -> ws2?item=TASK-1 (both workspaces owning TASK-1) kept
the predicate true across the switch, leaving collabKey pinned to ws1's
item.id and rawMode carried over until ws2's loadData resolved.

Stamp the wsSlug each item is loaded under (loadedItemWsSlug, lock-stepped
with item adoption inside the myItemGen===itemGen gate) and fold
loadedItemWsSlug === wsSlug into itemMatchesRef. scrollReady, collabKey,
resolvedIdentity, and the rawMode-reset gate all derive from it, so they
tighten together and stay consistent. Single-workspace usage is unchanged
(the arm is always true there).

TASK-2283.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 19:43:26 -04:00
xarmian 8c710e1db9 fix(web): stop paneOverlay ref-count effect self-looping on mobile (BUG-2284) (#1009)
PR #1007 (TASK-2131) added a PaneHost `$effect` that calls
`paneOverlay.enter()`/`leave()` to inert the app-shell chrome behind the
mobile detail-pane overlay. `enter()`'s `overlayCount += 1` READS
`overlayCount` inside that tracked effect scope, so the effect took a
reactive dependency on the very signal it writes: enter() dirtied the
effect → it re-ran → enter()d again → `effect_update_depth_exceeded`.
Svelte aborts the flush, stranding the rest of the subtree's reactivity —
`paneMintForRoute` stopped recomputing, so the mobile pane (and its Back
chevron) rendered EMPTY. The E2E `pane-controller` mobile-overlay tests
caught it; #1007's own manual check verified the ARIA attributes but not
that item content still rendered.

Fix: `untrack` the count read in enter()/leave() so a write from an effect
never establishes a self-dependency (the write still notifies the layout
reader). The ref-count mutators are written from effects by design, so the
untrack belongs in the store.

Also fixes the second collision from the same #1007 change: the pane is now
`role="dialog"` on mobile, so pane-controller.spec.ts:771's `[role="dialog"]`
+ text locator matched BOTH the pane and the BottomSheet (strict-mode
violation). Target the sheet by accessible name ("Quick actions") instead —
the pane's is "Item detail".

The effect_update_depth_exceeded runaway only manifests under the real
browser scheduler (not jsdom/vitest), so the E2E overlay tests own the loop
regression; the unit tests lock the ref-count semantics.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 19:30:11 -04:00