Commit Graph

1108 Commits

Author SHA1 Message Date
Claude a65bfcc4d4 fix(nix): skip DNS-dependent webhook validation subtests in checkPhase
ValidateWebhookURL does a real net.LookupIP as an SSRF guard, and four
TestValidateWebhookURL subtests exercise that path against example.com.
That works fine in CI (real network) but fails under a properly
sandboxed Nix build (no network), which is what real users hit. Skip
just those subtests; the rest of the package's tests (invalid schemes,
private-IP rejection, etc.) need no network and keep running.
2026-07-26 23:31:06 +00:00
Claude 02b302519e feat(nix): add flake packaging for pad with CI build
Adds a Nix flake exposing the pad binary as packages.default (buildGoModule
+ importNpmLock for the embedded SvelteKit UI), a devShell, and flake
checks (package build with `go test ./...`, plus a `pad --version` smoke
test). nix/package.nix is written nixpkgs-submission-ready (no
flake-specific inputs) so it can later be adapted for pkgs/by-name.

Also adds a GitHub Actions workflow that runs `nix flake check` and
`nix build` on push/PR, and documents `nix run` / `nix profile install`
/ `nix develop` in the README.
2026-07-26 22:49:10 +00: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
xarmian b76abdd66b fix(web): full ARIA-modal isolation for the mobile detail pane (TASK-2131) (#1007)
* feat(web): full ARIA-modal isolation for the mobile detail pane (TASK-2131)

Follow-up to TASK-2122. The mobile full-screen detail-pane overlay had a
JS focus trap + an inert list column, but the app-shell chrome behind it
stayed in the a11y tree and the pane was still just an <aside>. Complete
the modal:

- The mobile `.item-pane` becomes role="dialog" aria-modal="true"; the
  desktop split stays a bare <aside> (complementary landmark, non-modal).
- MobileContextBar + BottomNav (rendered in the workspace +layout, ABOVE
  the pane host) are marked `inert` while a mobile overlay is up, so they
  leave the focus order AND the screen-reader tree. A JS trap can't
  constrain an SR virtual cursor and aria-modal is unevenly honored, so
  the background chrome must physically drop out.

The chrome is a layout sibling the host can't reach by prop, so PaneHost
hoists "a mobile overlay is active" into a small ref-counted store
(paneOverlay.svelte.ts) the layout reads — one-way writer/reader split
per CONVE-1688. Ref-counted so an overlapping route-change remount can't
clear the signal early. The layout carries `inert` on display:contents
wrappers (cascades to the fixed chrome, adds no box).

Verified in a real browser (Playwright): mobile → dialog role + aria-modal
+ inert descendants unfocusable; desktop → bare aside + chrome interactive.

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

* fix(web): exclude the pane's dialog role from foreign-modal guards (TASK-2131)

The new mobile role="dialog" on the pane collided with three places that
treat ANY [role="dialog"] as a *foreign* modal that owns its own ESC /
focus — regressions the pane's <aside>-not-a-dialog invariant had been
silently relying on:

- paneFocus.ts PANE_EXEMPT_SURFACE_SELECTOR: any in-pane element matched
  closest('[role="dialog"]') → the whole pane read as an "exempt surface",
  killing the mobile Tab trap and confusing the focus-follows classifier.
- The collection + item-page ESC guards querySelector('[role="dialog"]')
  → the pane matched itself → ESC was swallowed instead of closing/popping
  the pane on mobile.

Fix: exclude the pane via [role="dialog"]:not(.item-pane) at all three
sites (a genuinely nested dialog/menu opened FROM the pane still matches).
Adds inExemptSurface unit coverage for the pane-not-exempt case.

Caught by the independent Codex review pass.

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

* fix(web): complete mobile modal isolation — banners + print (TASK-2131)

Two more members of the same [role="dialog"] collision class, from the
independent Codex pass:

- Print: app.css @media print hides [role="dialog"] to strip overlays.
  The mobile pane now matches, so printing at <=768px with the pane open
  dropped the whole item from the printout. Exclude via :not(.item-pane) —
  the pane is the content being printed, not a transient overlay.
- Banners: VerifyEmailBanner + ConnectBanner rendered OUTSIDE the inert
  wrappers, so their controls (Resend / Connect) stayed reachable by an SR
  virtual cursor behind the aria-modal pane — the same gap the inert of
  MobileContextBar/BottomNav closes. Fold them into the top inert wrapper
  so ALL app-shell siblings behind the overlay leave the a11y tree; only
  the pane (in children()) stays interactive.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 18:13:37 -04:00
xarmian 316e25a6ca fix(web): trap focus in BottomSheet so ESC/Tab hit the sheet, not the layer under it (BUG-2130) (#1006)
* fix(web): trap focus in BottomSheet so ESC/Tab hit the sheet, not the layer under it (BUG-2130)

BottomSheet is a role="dialog" aria-modal mobile sheet but, unlike the
native-<dialog> Modal.svelte, it never moved focus into itself or trapped
Tab. Two consequences, app-wide (most visible over the mobile split-pane):

- ESC closed the wrong layer: focus stayed on the trigger outside the
  sheet, so a window-level ESC handler underneath (e.g. the collection
  page's pane-close) fired first and closed THAT instead of the sheet.
- Tab escaped the sheet into the obscured content behind it.

Fix in the shared component, mirroring Modal.svelte's behavior:
- Move focus onto the panel (tabindex=-1) on open; restore focus to the
  trigger on close and on teardown-while-open.
- Trap Tab/Shift+Tab within the sheet, reusing the pane's already-tested
  trap math (paneFocusables + nextTrapTarget from paneFocus.ts) so the two
  focus traps can't drift.

The focus effect reads only `open`/`sheetEl` and writes the non-reactive
`previouslyFocused`, so it can't self-invalidate (CONVE-1688).

Surgical over a native-<dialog> rebuild: 11 consumers make the blast
radius large, and the bug is scoped to the shared component. Converging
BottomSheet onto the Modal primitive is a separate, larger refactor.

Adds BottomSheet.svelte.test.ts (focus-in, Tab/Shift+Tab wrap, Escape,
backdrop, focus-restore). Verified: full web suite (471) green, svelte-check
clean, Codex CLEAN, and a real mobile-browser drive (focus-in, Tab +
Shift+Tab trapped, ESC closes only the sheet with the item pane surviving,
focus restored to the trigger).

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

* fix(web): only the frontmost BottomSheet handles Escape/Tab (nested sheets)

Codex PR review caught an adjacent facet of the same layer-isolation bug:
every open BottomSheet registers a window-level Escape/Tab handler, so when
one sheet opens another (Quick Actions sheet → the mobile emoji picker's
sheet, both role="dialog" BottomSheets, the inner DOM-nested in the outer),
a single Escape fired both handlers and closed BOTH layers.

Gate each sheet's handler on being the frontmost (innermost) open sheet: a
nested child sheet renders inside our content, so a sheet that CONTAINS
another open `.bs-sheet` is not frontmost and stays out. Order-independent
by design — a defaultPrevented/stopPropagation check can't work here because
the outer sheet's window listener is registered first and fires before the
inner's.

Verified at runtime (mobile): open Quick Actions → New quick action → the
emoji-picker button opens a nested sheet; one Escape now closes only the
picker (Quick Actions survives), a second closes Quick Actions. Adds a
nested-sheet unit test. Full web suite 472 green, svelte-check clean.

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

* fix(web): generalize BottomSheet frontmost gate to sibling sheets too

Follow-up to the nested-sheet fix: replace the descendant-only guard with a
document-wide frontmost check so the "only the topmost sheet handles
Escape/Tab" rule also holds for sibling sheets (two open overlays where
neither DOM-contains the other). A sheet that contains a deeper open sheet is
never frontmost; among the remaining leaf sheets the last in document order
paints on top at the shared z-index, so it wins. Recomputed per keydown, so
order-independent.

Two full-screen overlays can't both be reached by the user today (opening one
covers every other trigger), so this hardens a currently-unreachable topology
rather than fixing a live repro — but it makes the invariant total and closes
the Codex review's remaining finding. The single-sheet path short-circuits to
frontmost=true, so the verified primary behavior is unchanged (re-verified at
runtime: single-sheet focus-in/trap/Escape/restore + nested one-layer-per-Esc
both still green). Adds a sibling-topology unit test.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 17:32:31 -04:00
xarmian 06d7e429e5 fix(web): keep the inline "New quick action" form open on click (BUG-2281) (#1005)
Clicking the QuickActionsMenu footer "+ New quick action" flipped
showCreateForm=true, unmounting the footer's {:else} branch (the very
button clicked). Svelte 5 flushSyncs after a delegated event handler, so
by the time that click bubbled on to the <svelte:window> click-outside
handler the button was detached — target.closest('.quick-actions-menu')
returned null, and handleWindowClick treated it as an outside click and
closed the whole menu, wiping the create form the instant it opened. The
create-form Cancel button had the same detach-then-close quirk (closed
the menu instead of returning to the action list).

handleTriggerClick already guards this with e.stopPropagation();
handleOpenCreateForm and the Cancel handler did not. Add the same guard
to both. Adds a Playwright regression test (mutation-tested: fails on the
pre-fix code, passes after) — the inline form is exercised in a real
Chromium event pipeline for the first time (jsdom doesn't reproduce the
mid-bubble detach, and the capstone spec only asserted the button was
visible, never clicked it).

Also documents BUG-2280 in ItemDetail.svelte: the QuickActionsMenu
oncollectionupdated callback's `{@const keyedSlug = itemSlug}` fence was a
Svelte-5 no-op, but the callback is already switch-safe by two independent
layers (the child-side collection-id guard reads the LIVE parent
collection and drops a cross-collection callback; loadData's identity
clause forces the correct collection regardless). Replaces the dead no-op
fence with a comment explaining why it's safe and warning against
re-adding a no-op snapshot fence (the literal BUG-2129 trap). No
behavior change in ItemDetail.

BUG-2281: real, fixed. BUG-2280: investigated, not a live bug (wontfix).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 16:50:23 -04:00
xarmian 96daff2e7c chore(deps): bump low-risk dev tooling + render libs (safe subset of #1002) (#1004)
Extracts the genuinely low-risk bumps from the grouped Dependabot PR #1002,
which as a whole can't be merged (it's stale — reverts the BUG-2278 advisory
overrides — and bundles a coordinated @tiptap/* 3.22.5->3.28.0 bump that needs
schema-version verification plus a svelte 5.55->5.56 runtime bump that needs
focus-suite revalidation).

Safe subset (dev tooling + rendering libs only; no collab/runtime/build-compiler
surface): @playwright/test 1.59.1->1.61.1, marked 18.0.3->18.0.7, svelte-check
4.4.7->4.7.3, mermaid 11.14.0->11.16.0, layercake 10.0.2->10.0.3,
svelte-dnd-action 0.9.69->0.9.74.

Deliberately EXCLUDED (verified unchanged): @tiptap/*, svelte,
@sveltejs/vite-plugin-svelte, yjs, and the kit/vite/rolldown toolchain — those
need their own validated PRs.

Gates: audit 0 prod vulns, check:tiptap-pins OK, npm ci in sync, build, check
(0 errors), test (464) all green.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 07:20:18 -04:00
xarmian 34233a2a25 fix(e2e): de-flake pane j/k re-target test by opening the first row (BUG-2279) (#1003)
pane-controller.spec.ts:160 flaked ~50% (fails once, passes on retry). Root
cause via instrumentation: the test opened a NAMED seeded row and pressed `j`
(down) expecting the pane to re-target to a different item. But the two seeds
share a same-second created_at, so their list order is a non-deterministic
tie-break (BUG-2270) — the named row could land LAST, where `j` clamps at the
final index (Math.min(idx+1, len-1)) and the cursor doesn't move. The
pane-follow then correctly finds the focused row is already the paned item and
skips (no re-target), so `openItemParam` stays put and the assertion fails.

Not a product bug — the follow logic behaves correctly. Fix is test-only: open
the FIRST rendered row instead of a named one, so `j` always has a row beneath
it to move to, regardless of seed tie-break order.

Verified: :160 now 10/10 stable in isolation (was ~50-60% flaky); full
pane-controller.spec.ts 21 passed.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-22 07:14:57 -04:00
xarmian add3ffabfb feat(web): adopt vite 8.1 / sveltekit 2.70 toolchain + fix pane pop focus (#1001)
Completes the deferred upgrade from BUG-2278. The advisory-fix PR #997 had
dragged this toolchain in via `npm audit fix`; #999 reverted it because it
regressed the pane-focus E2E suite. Root-caused (see BUG-2278 residual): the
trigger is @sveltejs/kit 2.66.0 (PR #15452), which blurs the active element
to <body> BEFORE the component update during navigation. On the pane's
popstate pop path (handlePaneBack -> history.go(-1), which can't carry
keepFocus), that early blur (focusout only, no focusin) makes Kit's end-of-nav
reset_focus() body.focus() a no-op emitting no focusin — starving PaneHost's
focusin-only backstop, so focus strands on <body>. (Drill path uses
goto({keepFocus:true}) and is unaffected — which is why only the 5 pop/ESC
focus tests failed. vite/rolldown/svelte are not implicated.)

Fix: re-assert focusPaneRegion() after the popstate settles (next frame, so
it runs after Kit's microtask-scheduled reset_focus), removing the dependency
on an incidental focusin(body). ~12 lines in paneHostController.ts; no-op when
the pane closed or focus already landed in-pane.

Toolchain: vite 8.0.11->8.1.5, @sveltejs/kit 2.59.1->2.70.1, rolldown
rc.18->1.1.5 (lockfile only; package.json caret ranges + advisory overrides
unchanged). Advisory deps stay at their patched versions (audit 0 prod vulns).

Verified on the bumped toolchain: the 5 previously-failing pane-focus tests
pass, full pane e2e 41 passed (the one flaky test, :160, is a PRE-EXISTING
flake on main that flakes on the reverted toolchain too and passes on retry),
web check (0 errors), test (464), build, tiptap-pins, npm ci all green.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 22:58:44 -04:00
xarmian dd79e837f6 fix(ci): narrow BUG-2278 web fix to advisory deps, revert toolchain slide (#999)
The broad `npm audit fix` from the previous commit (0f48bceb) fixed the
three production advisories but, as semver-compatible caret collateral,
also dragged the build toolchain forward (vite 8.0.11->8.1.5,
@sveltejs/kit 2.59.1->2.70.1, rolldown rc.18->1.1.5). That toolchain slide
regressed the timing-sensitive pane-focus E2E suite (PLAN-2154): E2E was
green on the two prior main runs and failed twice (initial + rerun) on the
broad-fix commit — a confirmed regression, not a flake. The advisory jobs
themselves (Go govulncheck, Web npm audit) went green.

This narrows the web fix to exactly the production advisories and holds the
toolchain at main's prior versions:
- dompurify (direct dep) ^3.4.2 -> ^3.4.12 (moderate XSS advisories)
- markdown-it override ^14.3.0 (moderate smartquotes DoS; transitive)
- linkify-it override ^5.0.1 -> ^5.0.2 (high, GHSA-v245-v573-v5vm; transitive)

`npm audit --audit-level=high --omit=dev` is 0 vulns (the CI Web gate). Two
dev-only advisories that the toolchain bump had incidentally cleared
(launch-editor high under vite, a moderate under @sveltejs/kit) are left
unfixed per repo policy ("dev-only advisories are informational; they don't
ship"). The Go x/text fix from 0f48bceb is retained unchanged.

Gates: web npm ci (in sync), check:tiptap-pins, audit (0 prod vulns), build,
check (0 errors), test (464) all green; toolchain verified unchanged.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 21:44:36 -04:00
xarmian 0f48bcebfa fix(ci): bump advisory deps to restore green CI (BUG-2278) (#997)
Two newly-published upstream advisories postdate the last green main run
and were failing the Go and Web CI jobs on every PR. Both are DoS-class
in parsing/text deps; no product code change.

Go job (govulncheck binary mode):
- GO-2026-5970: infinite loop on invalid input in golang.org/x/text.
  Bump golang.org/x/text v0.38.0 -> v0.39.0 via `go get` + `go mod tidy`.
  go mod tidy pulls the coordinated x/* release train it requires
  (crypto/term/mod/net/sys/tools). govulncheck -mode binary: 0 called.

Web job (npm audit --audit-level=high --omit=dev):
- linkify-it <=5.0.1 (high, GHSA-v245-v573-v5vm) + dompurify + markdown-it.
  `npm audit fix` (lockfile-only). Fixes the 3 advisories (audit now
  reports 0 vulns). As semver-compatible collateral within existing caret
  ranges it also refreshed the build toolchain (vite 8.0.11->8.1.5,
  @sveltejs/kit 2.59.1->2.70.1, rolldown rc.18->1.1.5). Tiptap exact-pins
  held (check:tiptap-pins green).
- Also tighten the existing linkify-it security override floor
  ^5.0.1 -> ^5.0.2 so it expresses the patched minimum for THIS advisory
  rather than relying on npm's latest-in-range resolution. Lockfile was
  already at 5.0.2, so npm ci stays in sync (verified).

Gates: go vet, go build, govulncheck -mode binary, go test ./... all green;
web npm ci, check:tiptap-pins, audit, build, check, test (464) all green.
Independent Codex review: CLEAN.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 21:16:48 -04:00
xarmian 51cd6e84e4 fix(collab): op-id durable fence for restore-rollback vs applier-ack race (BUG-2276 residual 2)
Closes the restore-rollback vs applier-ack clobber race with a durable operation-id correlation instead of a timing heuristic. The client brackets its setContent with an applier_apply_start{request_id} control frame; the server decides whether the external write persisted by reading the per-conn op-log high-water UNDER the same appendMu that sets the restore freeze (finalize-at-freeze — no drain, so a blocked write can't stall the restore; no timing window). Edges handled: unanchored conns are never elected; gate admission spans registration; legacy (pre-bracket) clients negotiate capability and an unconfirmable legacy round-trip returns a retryable 409 applier_ambiguous (fail-safe, never a clobber); the applier callback is synchronous-by-type so nothing can split the bracket. Normal acks stay on a lock-free, latency-identical fast path.

Confirming Codex (high effort): redesigned from a timing grace after review; 3 rounds on the op-id design (2 P1 -> 3 P1+P2 -> CLEAN/converging). E2E + Go(PostgreSQL) green; go test -race clean 8x. Go/Web CI red only on the pre-existing dependency advisories (BUG-2278).

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 20:51:19 -04:00
xarmian e601f2b368 fix(collab): reconcile Postgres commit-ack-loss on version restore instead of treating it as rollback (BUG-2276 residual 1)
On Postgres, a version-restore commit that durably lands but whose ack is lost surfaced as an error and wrongly resumed peers on a stale Y.Doc. ForceRefreshRoom now runs a Postgres-only reconcile after a commit error: two durable signals (content == restored version AND last_restore_seq advanced past a lock-captured baseline) must agree → LANDED (publish fences + reseed, return the restored item + SSE); both false → rolled back (unfreeze); disagree/read-error → UNCERTAIN (invalidate in-memory fences so durable state governs, then plain-close sockets so peers reconnect + re-evaluate). SQLite path unchanged.

Confirming Codex (high effort): 3 rounds — false-404, frozen-forever, archive-nil, stale-baseline, stale-in-memory-fence-clobber all closed; real Postgres end-to-end ack-loss + SSE test. make test-pg green. Residual 2 (applier-ack rollback race) follows separately. Go CI red only on the pre-existing govulncheck advisory (BUG-2278).

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 18:00:49 -04:00
xarmian 9d18f12893 fix(collab): flush live editor into items.content before version restore (BUG-2271)
Before a version restore, the initiating client flushes its live collab editor markdown into items.content (the collab server is a dumb relay and can't render the Y.Doc), so the server-side 'Restored from…' undo-point captures in-flight edits instead of losing them when the restore prunes the op-log. Best-effort: a genuinely-failed pre-restore flush warns the user (non-silent) and the restore still proceeds. Narrow reconnect/cursor-0 window documented as an accepted residual.

Confirming Codex (high effort): 2 rounds — silent-flush-fail + spurious-warning + E2E false-pass all closed; deterministic request-ordering E2E green. Web CI red only on the pre-existing npm advisory (BUG-2278).

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 17:27:02 -04:00
xarmian 3c4eee54ce fix(web): retarget URL/breadcrumb on collection rename across tabs (BUG-2272)
On a remote collection rename, retarget the full-page item view's URL + breadcrumb to the new slug (non-embedded only, query preserved) and make the collection route robust to chained/replayed renames. The rename-nav decision is a pure, unit-tested helper (resolveRenameNavTarget) scoped to stable collection identity, so a reused slug can't misdirect and a chained B→C in the goto→reload window isn't dropped.

Confirming Codex (high effort): 3 rounds — reused-slug misdirection + a transient it briefly introduced both closed; final logic verified + 8 deterministic tests + E2E green. Web CI red only on the pre-existing npm advisory (BUG-2278).

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 16:33:04 -04:00
xarmian 37ec77d110 fix(store): monotonic tie-breaker for same-second item version ordering (BUG-2270)
Adds a per-item monotonic `version_seq` column (dual migrations: SQLite 076 / Postgres 054, backfilled via ROW_NUMBER) so version-history RECONSTRUCTION resolves same-second versions deterministically instead of by the random-UUID PK. Reconstruction paths (shouldCreateItemVersion, ListItemVersions/Resolved, export) order by version_seq; the timeline keyset path (ListItemVersionsBeforeTime) keeps its id-consistent cursor.

Confirming Codex (high effort): found + fixed one keyset-pagination P2 (order/cursor key mismatch). make test-pg green (migration verified against Postgres). Go CI job red only on the pre-existing govulncheck advisory tracked in BUG-2278.

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 16:16:15 -04:00
xarmian 1f12b26f33 fix(web): item field edits use fields_patch + optimistic concurrency (BUG-2273) (#991)
Adopt the IDEA-1480 / MCP v0.14 item-level merge + optimistic-concurrency contract in the web editor's per-field save. `updateField` now sends a single-key `fields_patch` + `expected_updated_at` instead of a full `fields` blob, with a bounded refetch-and-retry on 409 `update_conflict`. Fixes concurrent-field-edit clobber and the schema-migration-race value restore. Includes the BUG-2129 E2E test update to the new wire shape.

Confirming Codex pass: CLEAN. E2E green on rerun. (Web/Go CI red only on pre-existing dependency advisories tracked in BUG-2278.)

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 16:08:55 -04:00
xarmian 40f88052cd fix(collab): version restore via prune+reseed (BUG-2264) (#990)
Version restore didn't reconcile the live Y.Doc: peers kept editing a
Y.Doc built on pre-restore ops, and their next collab-snapshot flush
clobbered the restored items.content. Reworked restore to prune+reseed —
the restored content becomes canonical and every peer converges on it
(unflushed edits are discarded, which is exactly restore semantics),
replacing the earlier applier/epoch/watermark routing.

handleRestoreItemVersion drives RoomManager.ForceRefreshRoom under the
per-item lock. Hardened across Codex xhigh review rounds:

- Atomicity: pre-prune MAX(op-log), the items.content write, the
  "Restored from…" version, the op-log wipe, AND both durable restore
  boundaries all run in ONE store transaction. A failed commit rolls back
  all of it — no divergent state, no fail-open boundary.
- Unambiguous commit signal: UpdateItem reads the updated row WITHIN the
  tx (getItemTx) before commit, so a read failure can't make a committed
  update look failed and the returned seq is this restore's.
- Restore freeze: conns are paused via a dedicated rc.frozen flag (NOT
  canWrite) so the auth-revalidation loop can't thaw the freeze mid-restore
  or promote a viewer; pickApplier + the applier-ack handler reject frozen
  conns so a concurrent external PATCH can't falsely succeed.
- Stale-flush boundary: pre-prune MAX+1 fences in-flight snapshot cursors
  under the same item lock.
- force_refresh fan-out deadlock: per-conn timer-close so a wedged
  writeLoop can't hang the fan-out + item lock.
- Stale-SEED clobber: the client announces the item.seq it seeded from
  (?content_seq=) on every (re)connect; Join force_refreshes any seed that
  predates the last restore.

Residual #1 (restart-durability) CLOSED durably, for BOTH stale vectors —
the in-memory fences didn't survive a restart, so a surviving cursor-0
pre-restore browser tab wasn't fenced on reconnect. Two nullable per-item
columns (migration 075 SQLite / pg 053), both stamped in the restore's own
tx (atomic with the content write + op-log prune):
  * items.last_restore_seq — the content generation. Join's stale-seed
    fence reads it (via store.ItemLastRestoreSeq) when the in-memory
    fast-path misses (after a restart); if that read errors, Join fails
    CLOSED via a RETRYABLE plain close (not a force_refresh, which would
    discard the Y.Doc and spin an unbounded refresh loop) so the client
    reconnects with backoff, Y.Doc intact.
  * items.restore_boundary_op_id — the op-log-id boundary. The
    collab-snapshot flush gate reads it (via store.ItemRestoreBoundaryOpID)
    when the in-memory RestoreBoundary misses (after a restart), failing
    closed (409) on a read error, so a surviving tab's stale HTTP flush is
    fenced too.
No SCHEMA_VERSION bump — durable columns are not a Y.Doc node-spec change.

Deferred to BUG-2276: (a) a Postgres commit whose ack is lost is treated
as rolled-back (needs commit-outcome reconciliation; SQLite unaffected);
(b) a restore rollback racing an in-flight external-applier ack can drop
the ack and retry/fall back (needs the applier flow serialised under
itemLock at a 30s-stall cost).

NOTE(BUG-2270): ForceVersion can mint same-second version rows; the
item_versions ordering tie-breaker is tracked separately.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 15:21:57 -04:00
xarmian 1dbe04399a fix(store): optimistic concurrency + sibling broadcast for collection settings (BUG-2265) (#989)
* fix(store): optimistic concurrency for collection settings writes (BUG-2265)

Collection-level settings (e.g. quick_actions) were written by reconstructing
the whole settings JSON from a caller's local Collection snapshot, and
UpdateCollection replaced the column with no concurrency check. Two ItemDetails
in the same collection (full-page pane host master + pane) hold independent
snapshots and clobbered each other.

Mirror the item optimistic-concurrency pattern (IDEA-1480): add
CollectionUpdate.ExpectedUpdatedAt; when set, UpdateCollection re-reads
updated_at atomically under the workspace write lock (SQLite BEGIN IMMEDIATE /
Postgres advisory xact lock) and returns CollectionUpdateConflictError on a
mismatch. Empty token keeps the legacy last-write-wins path unchanged for
CLI/MCP/API callers. No DB migration — reuses collections.updated_at.

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

* feat(server): collection.updated broadcast + 409 conflict mapping (BUG-2265)

- handleUpdateCollection boundary-validates expected_updated_at (400 on a
  malformed token) and maps store.CollectionUpdateConflictError to the shared
  update_conflict envelope (HTTP 409) — byte-identical wire shape to the item
  path, via the extracted writeUpdateConflictEnvelope helper.
- Add the collection_updated EventBus type and publish it after a successful
  update so sibling ItemDetails / collection pages refresh their independent
  Collection snapshot proactively, shrinking the 409 window. Routed by
  Collection (slug) through the existing SSE visibility filter.

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

* fix(web): 409-aware collection settings writes + sibling refresh (BUG-2265)

- CollectionUpdate carries expected_updated_at; add isUpdateConflictError.
- QuickActionsMenu sends the token and, on a 409, refetches the collection,
  re-appends the new action onto the FRESH settings, and retries once — no
  silent loss, no user-visible error.
- EditCollectionModal captures the token at open-time (edge-gated seed so a
  concurrent broadcast can't wipe in-progress edits) and shows a
  non-destructive "changed elsewhere, reload" message on 409 rather than
  auto-merging a full-form edit.
- Subscribe to collection_updated over SSE: ItemDetail and the collection page
  refresh their own Collection snapshot (gen/slug-fenced against the persistent
  pane host's no-remount switch), so siblings converge before the next save.

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

* fix(store): harden collection optimistic concurrency + web fetch ordering (BUG-2265, Codex round 1)

Address Codex review findings:
- [P1] same-second clobber: now() is one-second precision, so two guarded
  writes in the same second kept an identical token. The accepted write now
  advances updated_at strictly past the token (only when now() hasn't already
  moved on), making a stale-token replay deterministically conflict. Add a
  same-second regression test.
- [P1] tokenless-writer race on Postgres: the advisory lock only serialized
  writers that also took it. Replace it with a `FOR UPDATE` row lock on the
  in-tx re-read (Postgres) — SQLite's BEGIN IMMEDIATE already serializes every
  writer — so a concurrent tokenless UpdateCollection can't slip between the
  re-read and the UPDATE.
- [P2] rename broadcast: only publish collection_updated when the slug is
  unchanged. A rename's old-slug event would make siblings refetch a dead slug
  (404) and a new-slug event can't reach old-slug visibility snapshots; renames
  are handled by the existing navigation path.
- [P2] out-of-order refreshes: ItemDetail and the collection page now use a
  dedicated monotonic refresh counter so two rapid collection_updated fetches
  can't resolve out of order and clobber newer state (loadSeq/loadGeneration
  only bump on route/item loads).

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

* fix(store): make collection updated_at strictly monotonic for ALL writes (BUG-2265, Codex round 2)

The previous same-second advance ran only on guarded updates, so a tokenless
UpdateCollection could write the current second over a forced expected+1s,
regressing the concurrency token and letting a stale guarded client clobber
newer data (Codex P1).

Route every collection update through one small transaction that re-reads
updated_at (FOR UPDATE on Postgres; SQLite BEGIN IMMEDIATE covers it) and
derives the new timestamp atomically: strictly advance past the row's current
value when now() hasn't already moved on. This makes updated_at a reliable
concurrency token for guarded AND tokenless writers. Add a tokenless-monotonic
regression test.

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

* fix: close remaining collection-concurrency gaps (BUG-2265, Codex round 3)

- [P1] Board column reordering (handleGroupReorder) rebuilt the full schema
  from a stale local snapshot and wrote it with no token — a lost-update path
  identical to the bug being fixed. Now sends expected_updated_at and, on 409,
  refetches, re-applies the reorder onto the fresh schema, and retries once.
- [P2] The workspace settings page seeded EditCollectionModal from a
  page-load-time collections list, so a change that predated editing produced
  a false 409. It now refreshes the list on collection_updated (seq-guarded).
- [P2] collection.updated is now delivered to item-grant-only SSE subscribers
  for collections they can see — it's itemless but leak-free (only the slug),
  so guests' ItemDetail schema/settings snapshots converge too. Filter test
  extended.

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

* fix(web): switch-safety + conflict-merge fixes for collection writes (BUG-2265, Codex round 4)

- Board column reorder now ABORTS on a 409 (with a "reorder again" toast)
  instead of replaying a stale option order onto the fresh field, which would
  silently drop a concurrent option add/remove/rename. Reordering is cosmetic;
  never worth clobbering a real schema edit. Also captures ws/slug/base before
  the await and fences the write against a route switch.
- QuickActionsMenu captures workspace + collection identity BEFORE the first
  await, so a mid-save navigation can't make the 409 refetch/retry target the
  wrong collection (no guaranteed remount).
- Settings-page SSE refresh captures the workspace and drops the result if the
  workspace changed while fetching, so a slow refresh for workspace A can't
  overwrite workspace B's freshly loaded collection list.

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

* fix(store): sub-second collection updated_at token, no future drift (BUG-2265, confirming pass #6)

The same-second monotonic advance manufactured whole-second FUTURE updated_at
values; sustained >1 write/sec on one collection drifted arbitrarily ahead of
wall-clock. collections.updated_at is TEXT on both dialects and never compared
lexically (only via time.Equal + display), so switch the update write to
sub-second nowNano(): same-second collisions become near-impossible, so the
token advances naturally. Keep a strict-monotonicity guard but step by a single
NANOSECOND on the (now near-impossible) coarse-clock/step-back collision, so any
drift is bounded to nanoseconds. Dual-dialect; covered by make test-pg.

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

* fix(server): sanitize + always-broadcast collection event (BUG-2265, confirming pass #2,#3)

- #2 (P1): collection_updated is delivered to item-grant guests, but the event
  carried ActorName/Source, leaking the owner's identity + edit source. Strip
  them — publishCollectionEvent now emits workspace + slug (+ new_slug) only.
- #3 (P2): always broadcast (including on rename), routed by the OLD slug and
  carrying the NEW slug via a new Event.NewSlug field, so remote tabs on the old
  slug can re-target instead of silently 404ing on their next action.
Tests: assert no actor/source leak on a settings update; assert a rename routes
by old slug + carries new_slug.

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

* fix(web): decisive switch-safety + rename handling for collection writes (BUG-2265, confirming pass #1,#3,#4,#5)

- #1 (P1): EditCollectionModal captures the target collection id/slug/name/ws +
  updated_at when the form is SEEDED, and handleSave/handleArchive now operate on
  that captured identity (not the live props). The seed effect re-seeds when the
  collection IDENTITY changes (not on a same-id broadcast refresh), so a reused
  route can't leave A's form saving/deleting to B.
- #3 (P2): on a rename event the collection route navigates to the new slug
  (preserving the pane query) and ItemDetail refetches by new_slug; the SSE event
  type carries new_slug.
- #4 (P2): the reorder-conflict path refetches the collection (reseeds the token)
  before prompting, so a missed SSE event doesn't make every retry 409 forever.
- #5 (P2): QuickActionsMenu only invokes oncollectionupdated when the live
  workspace/slug still match the captured identity, so a reused route can't
  assign an old response to the newly-navigated page.

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

* fix(server): nano token round-trip, rename visibility, publish-before-migration (BUG-2265, confirming pass 2)

Address the round-2 confirming-pass findings (server-only):

1. (P2) The shared update_conflict envelope formatted actual_updated_at with
   second precision (time.RFC3339), truncating the now sub-second collection
   token so the client's retry token never matched — a permanent 409 loop.
   Format with time.RFC3339Nano. Item tokens are zero-nanosecond, so
   RFC3339Nano emits no fractional part — the item 409 wire shape is
   byte-identical and the item path still compares via time.Equal. Added a test
   that the returned token round-trips as a usable retry token.

2. (P2) Rename events are routed by the OLD slug, but a subscriber that
   revalidated after the rename only has the NEW slug in visibleSlugSet, so the
   visibility check dropped the event before the new_slug branch. Accept a
   rename when EITHER the old slug or the (authorized) NewSlug is visible;
   downstream item-grant gating uses whichever slug is visible. Filter test
   extended.

3. (P2) The collection_updated event was published only after field migrations
   succeeded, but UpdateCollection already committed (updated_at advanced). On a
   migration failure clients got a 500 and no refresh, leaving siblings with
   stale tokens that 409 blindly. Publish on the commit (before the migration),
   so siblings always resync regardless of migration outcome.

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

* fix: atomic collection update+migration; modal same-id rename retarget (BUG-2265, confirming pass 3)

Address the round-3 confirming-pass findings; defer cross-tab rename
RE-NAVIGATION to BUG-2272 (placeholder) per coordinator.

1. (P1) Migration atomicity. UpdateCollection committed the schema + concurrency
   token BEFORE MigrateItemFieldValues ran, so a migration failure returned 500
   with the row already changed → the retry was guaranteed to 409 and item
   values were left inconsistent with the committed schema. Made the two ATOMIC:
   extracted applyFieldMigrationsTx and run it inside UpdateCollection's own
   transaction (after taking the workspace seq lock), so a migration failure
   rolls back the schema AND the token — nothing changes, the retry works.
   The handler now passes migrations through instead of running them separately,
   and publishes the event only after the fully-atomic commit. store/tx work →
   make test-pg run green.

2. (P2) EditCollectionModal same-id rename. The round-1 identity capture ignores
   same-id prop refreshes (to preserve edits), but a concurrent RENAME changes
   the slug (not the id), so handleSave/handleArchive PATCHed a dead slug → 404
   before the token could 409. On a same-id prop change whose slug changed, the
   seed effect now retargets the endpoint slug + re-captures the token WITHOUT
   reseeding the form (in-progress edits preserved).

Deferred (BUG-2272, TODO comments added, already broken on main — no regression):
- ItemDetail full-page item URL/collSlug not retargeted after a remote rename.
- Collection route chained-rename events during SSE replay landing on a dead
  intermediate slug.

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

* fix(web): keep seeded token on same-id rename retarget (BUG-2265)

On the EditCollectionModal same-id rename branch, retarget the endpoint
(slug/name/ws) only — drop the token re-capture. Re-capturing let a later
handleSave succeed against the renamed collection and apply the modal's stale
pre-rename full form, silently REVERTING the concurrent rename (the exact
stale-snapshot clobber BUG-2265 prevents). Keeping the seeded token means a
concurrent rename correctly yields a 409 → the non-destructive "collection
changed, reload" message. Slug-retarget without token-recapture gives both:
no 404 (right URL) and no clobber (409 fires).

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

* fix: lock-order deadlock + unified collection-snapshot fences (BUG-2265, confirming pass 4)

1. (P1) DEADLOCK regression. UpdateCollection's atomic migration path took the
   collection-row FOR UPDATE lock and THEN the workspace seq lock, but item
   creation takes them in the reverse order (workspace advisory lock first, then
   the collection-row FK lock on INSERT) — a concurrent item-create +
   schema-migration ABBA-deadlocks on Postgres. Fix: acquire the workspace seq
   lock BEFORE the collection-row FOR UPDATE (matching item-create's order).
   Every store path that locks both now takes them workspace-seq → collection-row
   (tryCreateItem, UpdateItem, MigrateItemFieldValues, UpdateCollection). Added a
   concurrency regression test (item-create racing schema-migration); make test-pg
   green.

2+3. (P2) Cross-generation fence gap. The SSE collection refresh and route/item
   loads used SEPARATE counters, so a stale in-flight load could complete after a
   fresh SSE refresh and revert the collection + its concurrency token. Unified to
   a SINGLE monotonic collection-snapshot generation in BOTH the collection route
   and ItemDetail — every collection-snapshot write (loadCollection/loadData, the
   SSE refresh, reorder, and the quick-action/edit-modal callbacks) bumps it on
   start and gates its assignment on "still latest". ItemDetail's load keeps a
   switch-escape so a stale refresh for the OLD collection can't block loading a
   NEW one. Settings page unified the same way over its collections-list writes.

4. (P2) Settings page fed a stale editingCollection to the edit modal after a
   remote rename (its prop never changed → the same-id-rename retarget never
   fired → 404). The unified refresh now re-points editingCollection at the
   refreshed object for the same id, so the modal's retarget fires.

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

* fix: emit item-changes signal on field migration; QuickActions retry-by-id (BUG-2265, confirming pass 5)

1. (P1) A collection update that runs a field migration mutates item `fields`
   JSON and advances item `seq`, but only collection_updated was published —
   open item views refreshed collection METADATA and returned without
   reconciling the migrated items, so clients kept stale field JSON under the new
   schema and a later full-fields item update could UNDO the migration (a
   clobber). UpdateCollection now returns the migrated-item count; when > 0 the
   handler ALSO emits the existing bulk item-mutation signal (items_bulk_updated,
   Op=migrate) so open views reconcile via /items-changes. Fires only when the
   migration touched >= 1 item — a pure settings/quick-actions update emits
   nothing extra. No store SQL/locking change (Go signature + count plumbing
   only); make test / make test-pg both green.

2. (P2) QuickActionsMenu's 409 retry GET-by-slug 404s if the competing update
   renamed the collection. Resolve the fresh collection by STABLE id (list +
   find by id) before re-appending + retrying, mirroring EditCollectionModal's
   identity approach; the result-propagation guard is now id-based too so a
   rename doesn't spuriously drop it.

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

* fix: uniform sweep of item-grant delivery, rename routing, and 409/404 retries (BUG-2265, confirming pass 6)

One pattern-sweep instead of per-site patches. Audited every event this PR
publishes and every client retry path, applying three patterns uniformly:

PATTERN A (item-grant SSE reconcile) + B (old-slug rename routing): instead of a
SEPARATE items_bulk_updated migration event (which carries op/count for items an
item-grant subscriber can't see and isn't rename-routed), FOLD a SANITIZED
`items_changed` bool onto collection_updated — already item-grant-delivered
(round 3) and already old-slug-routed with new_slug (round 2). On it the client
triggers a /items-changes deltaSync (server-filtered to the caller's grants) and
ItemDetail refetches its open item, so item-grant EDITORS reconcile migrated
field JSON — closing the clobber where a stale full-fields update would UNDO the
migration. Leak surface: "a collection you can see items in changed [+ renamed +
had item changes]" — a bool, no per-item data. Removed the round-5
items_bulk_updated publish. The pre-existing items_bulk_updated (archive/move) is
untouched and correctly stays suppressed for item-grant users.

PATTERN C (409 AND 404 in retries): a competing RENAME can 404 a slug-targeted
write before it can 409, bypassing recovery. Added isNotFoundError /
isConflictOrNotFound helpers; every write/retry path now treats BOTH: QuickActions
save resolves-by-id and retries on either; board reorder reseeds-by-id and aborts
on either; EditCollectionModal save shows the reload prompt and archive
resolves-by-id and retries on either.

Tests: server asserts collection_updated sets items_changed on migration (not on
settings-only) and stays sanitized; the SSE-filter test asserts the migration
variant reaches item-grant subscribers for a visible collection; web unit tests
assert 404/409 classification and a real component-driven not_found -> resolve-
by-id -> retry in QuickActionsMenu. make test / make test-pg / npm run test all
green.

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

* fix: stable collection-ID identity for collection events + request-based items_changed (BUG-2265, confirming pass 7)

1. (P1) Collection events were identified only by MUTABLE, reusable slugs, and
   events replay — so a stale rename event's old slug, once re-owned by a
   DIFFERENT collection, could pass a slug-based match and misroute a client
   (navigate away / load the wrong schema) or leak the new slug. Fix at the ROOT:
   carry the STABLE CollectionID on collection_updated (Event.CollectionID) and
   match by ID everywhere:
   - Server visibility: sseEventVisibleFor matches collection_updated on a new
     visibleCollIDSet (built from the same VisibleCollectionIDs), not the slug —
     so an event for a collection the subscriber can't see by ID is dropped even
     if its (reused) slug is in visibleSlugSet. Filter test proves the slug-reuse
     drop.
   - Clients: ItemDetail and the collection route match `event.collection_id ===
     <their collection>.id`, not slug. Slug(s)/new_slug stay only for the
     rename-navigation URL. Settings refreshes its whole list (already id-safe).

2. (P1) items_changed was keyed off the affected-ROW count, delivered to
   item-grant subscribers → a subscriber whose own items were unaffected could
   infer that HIDDEN items matched the migrated value. Now keyed off whether a
   field MIGRATION WAS REQUESTED (len(input.Migrations) > 0), independent of row
   count — leaks nothing about hidden item values. Reverted round-5's
   UpdateCollection count-return (no longer needed). Test: a migration matching
   ZERO items still sets items_changed.

Deferred with markers:
- NOTE(BUG-2273) at ItemDetail's reconcile-skip AND updateField: the web editor's
  full-fields field write lacks item-level OCC (never adopted IDEA-1480/v0.14),
  so the migration reconcile is best-effort.
- TODO(BUG-2272) at the reorder 404 reseed: it refreshes `collection` but not the
  route `collSlug` (renavigation, deferred).

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

* fix: archive OCC (no destructive wrong-target) + settings load fence (BUG-2265, confirming pass 8)

1. (P1) EditCollectionModal handleArchive resolved the target by stable id but
   the server DELETE re-resolves by the MUTABLE slug — a rename that re-owned
   that slug before the delete landed would archive the WRONG collection.
   Close the TOCTOU with an expected_updated_at OCC on the delete, mirroring the
   update OCC: DeleteCollection re-reads updated_at under a lock (FOR UPDATE on
   Postgres) and 409s on mismatch; the handler validates the token + maps the
   409; the client sends it as a query param; handleArchive passes the seeded
   token (and the fresh token on the resolve-by-id retry). A reused slug or a
   concurrently-changed target now yields a clean 409 → the reload message,
   never a wrong-collection archive. Server test: stale token 409s (and the
   collection survives); current token 204s; malformed 400s; no token 204s.

2. (P2) settings load(): the generation was bumped AFTER awaiting setCurrent, so
   a slow load for workspace A could resume after B's load and clobber B's
   name/context/collections/members. Capture a dedicated loadGen at load() ENTRY
   (before any await) and fence EVERY continuation on it; the collections write
   additionally respects collectionsGen so it can't revert a fresher SSE refresh.
   Using a dedicated loadGen (not the SSE-shared collectionsGen) means an SSE
   collections-refresh mid-load doesn't drop the name/members writes.

Deferred: TODO(BUG-2272) at the collection route's rename-navigation site — the
global collectionStore (sidebar/pickers) isn't refreshed and the workspace
layout ignores collection_updated, so the sidebar keeps the dead slug. Layout-
level renavigation, deferred.

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

* fix(web): dedicated item-snapshot fence + id-based rename comparisons in ItemDetail (BUG-2265, confirming pass 9)

One comprehensive ItemDetail async-snapshot fence sweep so this file's item/
collection fencing is uniform and ID-based.

1. (P1) The migration item-refetch and loadData shared loadGeneration, so the
   refetch could apply migrated fields and then a stale loadData response
   overwrite them (a later full-fields edit then undoes the migration). Added a
   DEDICATED itemGen (separate from loadGeneration and collectionGen), bumped at
   the start of BOTH loadData's item load AND the migration refetch, and gated
   BOTH `item = ` writes on "still latest itemGen" — neither can stale-overwrite
   the other. Swept the other PASSIVE item snapshot-refreshes onto itemGen too
   (SSE item_updated/archived/restored, onSync deleted/incremental/full, the
   collab refresh) so they're ordered against each other and the migration/load.

2. (P2) A settings update that follows a rename before the rename fetch completes
   requested the OLD slug and bumped collectionGen, cancelling the valid rename
   fetch. Fetch slug is now `event.new_slug || event.collection || slug`.

3. (P2) The loadData collection fence-escapes compared the stale load's SLUG vs
   the freshly-renamed snapshot's slug (they differ on a rename → escape let the
   stale result overwrite). They now compare stable collection IDs; the SSE
   refresh's post-fetch identity check is id-based too.

Audit (site -> generation -> id?): every PASSIVE snapshot-refresh (loadData
item+collection, migration refetch, SSE x3, onSync x3, collab) bumps the correct
dedicated gen (item->itemGen, collection->collectionGen) and compares identity by
id. The DELIBERATE user/action writes (title/field/tag/assignee/role/content/
link/version/restore saves) keep loadGeneration + item-id switch-safety; their
item-snapshot concurrency vs the migration refetch is the deferred item-OCC gap
(BUG-2273, best-effort) — reordering them last-started-wins is orthogonal to that.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 09:43:05 -04:00
xarmian d14bceb3e2 fix(web): render the Rich/Markdown mode toggle on the peeking side too (BUG-2263 follow-up) (#988)
The invisible-freeze work (PR #987) left ONE surface still gated on
`!peeking`: the editor's Rich⇄Markdown mode toggle. It was hidden on the
passive preview because it's a provider-LIFECYCLE control — switching to
Markdown nulls collabKey and DESTROYS the retained collab provider, which
retain-alive (D2) forbids WHILE peeking.

But under focus-follows-editing a click on the toggle fires the host's
pointerdown-capture activator FIRST, flipping activePane to that side
(peeking=false) before the click's onclick runs. So by the time the flip
executes, the side is already ACTIVE and tearing down its own provider is
normal active-side behavior — the "teardown while peeking" the gate feared
can't happen via a click. The onclick's existing `if (peeking) return`
guards (plus the `|| peeking` mid-flush rechecks) remain as the backstop
for a re-peek DURING the async flush (e.g. the user clicks the other side
mid-flip).

So drop the `{#if !peeking}` render gate — the toggle now renders on both
sides like every other invisible-freeze surface. Verified in the browser:
opening the pane shows the toggle on the peeking preview; clicking the
peeking master's "Markdown" button activates it and flips to raw mode in
ONE gesture, ProseMirror unmounts cleanly, exactly one typeable editor
throughout, zero console errors.

Tests: FreezeProbe renders mode-toggle unconditionally; masterFreeze
asserts it present while peeking; new host e2e opens the pane, confirms the
toggle on the peeking side, and asserts the one-gesture flip (a successful
flip proves activation preceded the guarded onclick).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-20 20:06:13 -04:00
xarmian 29e49e4c63 fix(web): make the master/pane freeze invisible to the user (BUG-2263) (#987)
On the full-page item host, opening a detail pane froze the non-active
side by DEGRADING its DOM — fields became plaintext, buttons vanished,
the title turned read-only. Under the focus-follows-editing model
(PLAN-2179) the freeze is transient and one-click-reversible, so that
degradation was pure user-visible friction: you'd click a plaintext
field, the click would flip activePane, the field would re-render into a
live control, and you'd have to click again.

The freeze exists ONLY to keep exactly one TYPEABLE collab content editor
(single-owner of the editorStore/activeItem/tab-title singletons). It is
NOT a data-collision barrier: master and pane are always DIFFERENT items,
whose collab state is fully itemID-keyed / instance-local, and most REST
surfaces (fields, title, assign/role, tags, move, delete, share,
relationships, children, comments, reactions, archived restore, star) are
single-item, server-gated, side-independent writes.

So drop the `!peeking` term from those REST surfaces — gate them on
`canEdit` alone (their pre-freeze contract) — and keep `peeking` ONLY on
the content editor and its chrome (rich + raw editors, bubble/link
popover, provider-lifecycle mode toggle + retry). The content editor is
already invisible: the host's pointerdown-capture flips activePane before
the click's caret placement (TASK-2180 no-remount reactive editable), so
one gesture activates the side and lands the edit. Now the whole side is:
click anywhere -> edit it, no visible mode.

Two surfaces are NOT side-independent and stay confined to the active side
(the two documented exceptions, found by Codex review):
 - Version restore REST-writes this item's `items.content` directly, which
   collides with the retained Y.Doc on a peeking side. Kept frozen via a
   new ItemTimeline `restoreFrozen={peeking}` prop; comments/reactions
   (separate REST entities) stay live.
 - The quick-actions "Manage/New" controls rewrite the whole collection
   `settings` from a per-item snapshot (last-write-wins across two items in
   one collection), so they gate on `isOwner && !peeking` and recheck
   canEdit at dispatch; the read-only prompt-copy actions stay visible on
   both sides.

Scope: full-page host only. The collection route never passes peeking, so
`mutationsEnabled === canEdit`, `frozen={peeking}` is inert, and
`restoreFrozen` defaults false there — every change is byte-identical on
that route. `mutationsEnabled` survives but now scopes to content-editor
chrome only.

Tests: rewrote the masterFreeze unit probe + both full-page e2e specs
(host + capstone) to assert the new contract — the frozen side keeps its
editable title/fields/buttons; only its content editor flips
contenteditable=false. The freeze signal moved from `h1.title-readonly`
to the ProseMirror `contenteditable` attribute. Added a runtime-mutation
e2e assertion (a field edit on the frozen master PATCHes the correct item),
a real-QuickActionsMenu integration test, and unit coverage for the two
exceptions.

Two PRE-EXISTING concurrency issues were surfaced by the review (version-
restore gating + collection-settings write-exposure are byte-identical to
main, so this PR neither introduces nor worsens them); filed as BUG-2264
(restore <-> Y.Doc reconciliation) and BUG-2265 (collection-settings
optimistic concurrency).

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-20 18:55:25 -04:00
Dipak Chaudhari 69b361d7b2 docs: document shell completion setup in the README (#974)
Adds a Shell completion section to the CLI Reference covering install
steps for bash, zsh, fish, and PowerShell, and calls out the dynamic
completions that already exist (collection names, --workspace,
--status/--priority).

Closes #905
2026-07-20 18:02:50 -04:00
Dipak Chaudhari 6fcc20fb33 refactor(web): remove dead EditorToolbar.svelte (#973)
Never imported or mounted anywhere (repo-wide search confirms zero
references). The live insert surfaces are the slash menu, the mobile
toolbar in Editor.svelte, and the selection/bubble menu.

Closes #903
2026-07-20 18:02:47 -04:00
xarmian b447ce9938 feat(web): focus-follows-editing (activePane) on the full-page pane host (TASK-2181) (#986)
* feat(web): focus-follows-editing (activePane) on the full-page pane host (TASK-2181)

On the full-page item host, editing now FOLLOWS FOCUS: opening a pane keeps the
master editable and shows the pane as a read-only preview (DR-2); clicking a side
makes it the editable one and freezes the other. Exactly one side is editable at
any moment — the two-editor collision the freeze prevents still holds, now
dynamically. Builds on the TASK-2180 reactive freeze, so flipping `peeking` is a
cheap toggle (no editor remount).

- Host route ([collection]/[slug]/+page.svelte): adds `activePane: 'master'|'pane'`
  ($state, seeded viewport.isMobile ? 'pane' : 'master' for cold-load; forced to
  'pane' on the mobile-breakpoint transition). Master `peeking={!!openItemRef &&
  activePane==='pane'}`. First-open re-seeds the active side; drill / in-pane Back /
  ESC-pop set 'pane' via the controller's focusPaneRegion dep (single wire point).
  A document focusin classifier + capture-phase pointerdown activator flip
  activePane only on a CHANGED region, classifying against BOUND elements
  (itemPageEl / PaneHost.getPaneRegion()), exempting portalled surfaces (shared
  inExemptSurface) and ignoring bare-<body> drops.
- PaneHost.svelte: optional `activePane` prop (unset on the collection route →
  byte-identical). Forwards `peeking={activePane==='master'}` to its inner
  ItemDetail; exposes getPaneRegion(); the desktop focusin backstop only pulls
  focus back while activePane !== 'master' (so it no longer fights master
  activation; collection-route behavior preserved when the prop is unset).
- paneFocus.ts: extracts the shared inExemptSurface() set (reused by the mobile
  trap and the host classifier).
- ItemDetail.svelte: a FROZEN (peeking) instance never claims the singleton
  collectionStore.activeItem / editorStore — loadData gates setActiveItem +
  resetForDoc + setLastSaveTime on !peeking; onDestroy gates resetForDoc on
  !wasPeeking; the freeze-END reclaim is gated on itemMatchesRef and restores
  editorStore dirty/lastSaveTime from the instance's local shadows, so the
  singletons always follow the active side.
- +layout.svelte: fences the self-save-suppression's late setActiveItem
  continuation on the current activeItem (the ping-pong can switch sides mid-await).

Scope: full-page-host only; the collection route is untouched (pane stays always
editable there). No {#key} added (freeze is reactive). Known deferred R9
singleton-editorStore coupling for in-flight saves tracked in BUG-2184.

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

* fix(web): frozen side never writes singleton editorStore on remote collab; first-click drill from a frozen preview (TASK-2181)

Two in-scope fixes from an independent review pass:

1. A FROZEN (peeking) ItemDetail must not clobber the singleton editorStore that
   the ACTIVE side owns when REMOTE collab traffic syncs into its still-live Y.Doc.
   handleContentUpdate's collab path now gates editorStore.setDirty(true) on
   !peeking, and the collabFlusher save callback gates the singleton
   setLastSaveTime/setDirty(false) on !peeking. The per-instance shadows
   (localDirty/localLastSaveTime), the retain-alive snapshot persistence
   (collabFlusher.schedule), and the per-instance saveStatus/showSaved all stay
   unconditional — so the un-freeze END-reclaim still restores correct values and
   +layout's self-save suppression for the active item is no longer corrupted by a
   preview's background sync. (Distinct from BUG-2184's deferred pre-freeze
   continuation.)

2. A content-link / child-row drill from a FROZEN preview now works on the FIRST
   click. The focusin + capture-phase pointerdown detectors exclude navigable drill
   targets (isNavigableDrillTarget = closest('a[href]')), so they no longer flip
   activePane mid-gesture — which re-inited ChildItems' live dndzone (dragDisabled
   tracks the freeze) and swallowed the click. The click's own drill
   (navigatePaneTo → focusPaneRegion) sets activePane='pane', so the link drills AND
   activates the pane in one click. Exclusion covers BOTH detectors because
   Chromium/Firefox focus an <a> on mouse-click. Removed the capstone's
   pane-activation workaround and assert the first-click drill instead.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-20 13:37:42 -04:00
xarmian 3525171886 refactor(web): reactive master/pane freeze — drop the peeking-driven editor remount (TASK-2180) (#985)
Make the `peeking` master-freeze work WITHOUT remounting the Tiptap editor, so
a future focus-follows model can flip it on every focus switch smoothly
(PLAN-2179 DR-1). Behavior stays byte-identical for `editable=true` callers —
this only ADDS runtime `editable` gates and removes a remount.

BlockDragHandle was the SOLE construction-gated freeze surface (registered via
`editable ? [...] : []` in Editor.svelte). Make it reactive-editable-aware
instead:
- Register BlockDragHandle unconditionally.
- Choke handle visibility on `editorView.editable` in onMouseMove + the plugin
  update() (ProseMirror recomputes view.editable before plugin views update, so
  a freeze hides the handle synchronously — no remount).
- Bail every mutation dispatch site on `!editorView.editable`: startDrag,
  executeMove, the endDrag move dispatch, showMenu, and the four menu-action
  listeners (turn-into, duplicate, delete, attach) plus the deferred
  attach-picker change handler.
- Drop `peeking` from ItemDetail's editor `{#key}` (keep item.id +
  forceRefreshNonce). The freeze now works via `editable={!peeking}` reactively.

Dropping the remount reopened the "late async continuation / lingering UI"
holes the remount used to close by destroying the editor. Gate each reactively
(no remount reintroduced) so a frozen master still can't be mutated:
- EditorBubbleMenu create: drop the wiki-link insert when `!originEditor.isEditable`.
- htmlBlock commit(): bail on `!editor.isEditable` (a block already in source
  mode keeps its native textarea editable across the freeze).
- Editor slash / `[[` pickers: bail execSlash/execLink on `!editor.isEditable`,
  render-gate the menus with `&& editable`, and dismiss them on the freeze.
- ItemDetail source-refresh: add `!targetEditor.isEditable` to the post-await
  guard so a pre-pane-confirmed content REPLACE drops on a frozen doc.

Everything else the old remount cited was already reactive-gated (attachment
paste/drop, clipboard, image rotate/crop via `!view.editable`; mobile/table
toolbars via `{#if editable}`) — verified. Freshened the now-stale `{#key
peeking}` comments in attachment-upload.ts / attachment-image.ts. Reactive
freeze strictly improves BUG-2177 (no freeze-driven remount to orphan in-flight
editor actions).

Tests:
- blockDragHandleFreeze.svelte.test.ts — real Tiptap editor asserts the handle
  hides on freeze, no view/DOM remount across an editable flip, every mutation
  path bails while frozen, and the delete path still fires when editable (pure
  superset).
- pane-full-page-host.spec.ts — real-browser guards: opening/closing the pane
  freezes/thaws the master editor WITHOUT remounting its DOM node
  (contenteditable flips in place); the master block drag handle appears on
  hover when editable, never while peeking, and returns on close.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-20 11:26:40 -04:00
xarmian 3725d968e9 test(web): Phase-2 CAPSTONE e2e for the full-page pane host (TASK-2175) (#981)
Capstone runtime verification for PLAN-2154 Phase 2 (the full-page pane
host, TASK-2170–2174), mirroring the Phase-1 R14 async-race capstone
(TASK-2167) but exercising the properties the full-page host alone
introduces:

- Option-A mutation-SILENCE (the D2/HT-2176 freeze acceptance): while a
  pane peeks beside the retain-alive master, the key NEW-edit-INITIATION
  surfaces are disabled/absent (title click-to-edit, field inputs, comment
  composer, contenteditable editor, star/Share/Quick-actions/Move/Delete/
  relationship add+remove); un-peeking restores them. Asserts initiation
  surfaces, NOT zero writes (a pre-pane pending save + remote collab sync
  are expected under Option A). Exhaustive per-path freeze stays unit-tested.
- Bounded two-WS cost while peeking: opening the pane yields master +
  pane = 2 collab WS to distinct rooms (total-live ceiling asserted), the
  master's own room never gets a second provider, a drill re-targets the
  one pane provider (not N). Plus a WS-instrumented self-collision test:
  a cold-loaded ?item=<master> mints NO second provider on the master room.
- Host-side R14 late-async continuations: drill-right-after-Back (stale
  back-settle can't revert), close-then-reopen (stale loadData can't
  clobber — sub-resource GET proof by loaded slug), a held Back burst
  (coalesces to exactly one mint), and a rapid double-close (one
  history.go, never overshoots the master route).

Adds a localStorage-gated `__padPaneController` test hook to the host
route (mirroring the collection page's), so the R14 drill/close
continuations are synchronously drivable under adversarial timing. Zero
production surface.

Gates: svelte-check 0 errors; vitest 444 green; full pane e2e green at
--workers=1 (existing 54 + 7 new).

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