mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 10:03:29 +00:00
059cbcdcf3249c5ca88ae0ff6de61ecb6baecffa
582 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
059cbcdcf3 |
fix(web): pane tabs activate on pointerdown (focus-follows click-swallow, CI-caught) (#1028)
* fix(web): pane tabs activate on pointerdown — the focus-follows cascade could swallow the click on a peeking master (CI-caught) The E2E (Playwright) job caught what fast local runs missed: clicking a peeking master's tab fires pointerdown (focus-follows flips activePane → peeking-state re-render cascade) and on slow runners the subsequent click lands after the churn and is swallowed — activeTab never set, the Details panel never shows, fill times out. Same same-click detach class as BUG-2281. Activating on pointerdown (click retained for keyboard) sets the tab in the same tick as the detector, before any re-render can intervene. Verified: the two CI-failing specs at --repeat-each=3 locally, 45/45. * fix(web): pointerdown tab activation is mouse-only (touch scroll-start must not switch panels — Codex) |
||
|
|
d04b714ccb |
feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294) (#1027)
* feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294)
PLAN-2290 Phase 4, PR A. The mock's tabbed pane, built on the hard rule:
panels are CSS-hidden (.tab-hidden, display:none), NEVER {#if}-unmounted —
the collab editor, ChildItems/ItemTimeline SSE subscriptions, and
BacklinksPanel's count callback all carry mount side effects that must
survive tab switches.
- ItemDetail: pane-tabs tablist after the action bar; Details wraps Code
Context + .item-body (fields+editor, layout-{layout} preserved);
Relationships wraps relationships/add/children/backlinks (inside the
existing {#key itemSlug} block); ONE ItemTimeline instance serves both
Activity and Versions via the new visibleKinds render-filter. Tabs reset
to Details on item switch (guarded plain-let effect, no read-write loop).
Jump buttons switch-tab-then-scroll. Print shows all panels, no tab bar.
Tab clicks stay interactive while peeking and activate the side per the
focus-follows-editing model (deliberately NOT an exempt surface).
- ItemTimeline: visibleKinds?: ('comment'|'activity'|'version')[] —
filter-only over the one merged feed (no refetch on switch); composer
renders only when comments are visible.
- E2E: five specs updated — tab-click preludes where interactions target
tabbed sections; four frozen-master tests reworked to assert per-tab
visuals BEFORE the peek and DOM-based freeze proxies during it (the
per-surface freeze audit lives in masterFreeze/mutationGate unit suites).
Gates: svelte-check 0 errors, 488 unit tests, the five affected e2e specs
27/27 locally; runtime-verified collab badge synced across a full tab
round-trip, version filter (4 real cards), composer placement, editor DOM
alive throughout.
* fix(web): pane-tabs review fixes — title-Enter surfaces Details before editor focus; ARIA ids/roving-tabindex/arrow nav; block-drag hover integration restored via re-peek
Codex findings on #1027: (1) Enter-after-title-edit now sets
activeTab='details' + tick before focusing the editor (was focusing a
display:none node from other tabs); (2) tablist gains arrow-key roving
focus, per-instance aria-controls/id pairing ($props.id() — two ItemDetail
instances mount on the full-page host), tabindex discipline; (3) host
test 3 regains the end-to-end hover assertion: re-surface master Details
(activates), re-peek via the pane, hover the frozen editor, assert the
handle stays display:none — the reactive-editable choke verified in
integration again, not just by contenteditable proxy.
* fix(web): pane tabs use automatic activation on arrow nav (Codex — roving tabindex must follow focus; activation is free on display-toggled panels)
|
||
|
|
1747054b10 |
feat(web): toolbar consolidation — View menu w/ saved views, sort/filter icons, collection ⋯ menu (TASK-2293) (#1025)
PLAN-2290 Phase 3, PR B. The collection-page desktop toolbar collapses from nine controls to five, per the refresh mock: - View dropdown (Menu primitive, trigger shows current view): List/Board/ Table as checked rows + the saved-views set folded in (activate rows, hover-revealed delete, 📌 default marker, Make/Remove default, 'Save current view…'). The saved-views tab bar is retired — TASK-1366 pin/default semantics carry over unchanged. - Sort select becomes an icon + Menu (menuitemradio rows; BottomSheet on mobile); hidden in table view as before. - Filters becomes an icon button with the active-dot riding its corner; the FilterBar expansion behavior is unchanged. - Archived toggle, Edit collection, Share collection move into a ⋯ Menu (owner-gated rows; BottomSheet on mobile). QuickActions ⚡ and + New stay. - Mobile view chip + sheet unchanged. 29 dead CSS blocks deleted (svelte-check-verified); saved-view delete button re-revealed on row hover (was tab-hover). Zero e2e coupling: suites pin views via ?view= URLs, none target toolbar selectors (verified). Gates: svelte-check 0 errors, 488 tests; both menus runtime-verified via Playwright interaction. |
||
|
|
e9e114e96c |
feat(web): TableView + share parity; fix subgrid collapse + hyphenated lane accents (TASK-2293/2208/2213) (#1024)
* feat(web): TableView + public-share parity; fix subgrid collapse and hyphenated lane accents (TASK-2293, TASK-2208, TASK-2213) PLAN-2290 Phase 3, PR A2. Parity: TableView status cells become Chip primitives (click-cycle + per-row pulse preserved; read-only tables get static chips), select-value cells colored via fieldColors, focused row = violet tint + accent bar (.table-row/.focused class names kept for e2e); Public* fork (card/list/table/expansion) gets the card-token skin and chip-style pills through the terminal-aware fieldValueColor, multi-select values render as purple tag pills. TASK-2208 (audit): content-visibility:auto implies layout containment, which disables subgrid per spec — every table row collapsed to a single stacked column in Chromium (internal AND public share). Fixed by making the column template fully extrinsic (minmax+fr, no auto tracks) so rows align identically via grid-template-columns: inherit. Runtime-verified: data rows 72px wrapped (was 243-309px stacks). TASK-2213 (audit): columnAccentClassFor now derives lane accents from the canonical STATUS_COLORS map (normalizes hyphens — the default template ships 'in-progress'), and negative-terminal lanes (cancelled/rejected/ wontfix) no longer read done-green. Gates: svelte-check 0 errors, 488 tests; table runtime-verified both the row geometry and the chip rendering. * fix(web): fence TableView pulse timer with a sequence guard (Codex — same-row double-click cleared the second pulse early) |
||
|
|
f994509289 |
feat(web): card anatomy — Chip status/priority, card tokens, violet ring, lane accents (TASK-2293) (#1023)
* feat(web): card anatomy per the refresh mock — Chip status/priority, card tokens, violet selection ring, lane accents (TASK-2293) PLAN-2290 Phase 3, PR A. ItemCard (shared by Board/List/starred/tags/roles): - Skin: --card-bg/--card-border/--radius-lg/--shadow-card; hover = border-strong (no transform — svelte-dnd-action owns card transforms); .focused becomes the mock's violet ring + glow (e2e asserts the CLASS, which is unchanged). - Anatomy: ref stays top-left; star moves to the right cluster before the kebab (ONE auto margin on the star — competing autos split the gap). - Status/priority render as Chip primitives (tinted pills; status keeps click-cycle + pulse via Chip props; labels Title Case, no more uppercase). - Tags become purple-tinted pills; leading separator before parent chip dropped (chips separate visually). - Dead CSS removed (meta-status family, status-pulse keyframes). Lane accents: columnAccentClassFor (shareView — shared with the public fork by construction) gains col-open for open/new/todo/planned; BoardView + PublicBoardView underline it --status-blue. Default underline unchanged for custom vocabularies. Gates: svelte-check 0 errors, 488 tests; board+pane screenshots verified in both themes. * fix(web): consolidate BoardView lane accents onto shared mapper + AA chip text in light theme Codex findings on #1023: (1) BoardView had its OWN columnCssClass — a fifth parallel status-color-ish map, so only public boards got col-open; it now delegates to shareView.columnAccentClassFor (in-app and public boards can't drift, and custom terminal lanes now read as done in-app too). (2) New --chip-text-mix token (100% dark / 72% light) darkens chip text on light surfaces — all chip colors verified >=5.8:1 on white (computed). * refactor(web): columnAccentClassFor moves to $lib/utils/fieldColors (Codex — dependency direction); shareView re-exports |
||
|
|
01a94d93a8 |
feat(web): Menu/MenuItem primitive — 3 menus migrated, escape-stack + portal + pointerdown dismissal (TASK-2292) (#1022)
* feat(web): Menu/MenuItem primitive — escape-stack ESC, portal mode, pointerdown outside-click (TASK-2292) PLAN-2290 Phase 2, PR 4 (final primitive). New shared machinery: - lib/components/common/Menu.svelte — anchored + portal modes (portal = fixed coords + flip/clamp, escapes card content-visibility containment), instance-scoped POINTERDOWN outside-click (structurally removes the BUG-2281 stopPropagation-on-rows detach workaround), ESC via the shared escapeStack at new priority menu=40 (one ESC closes menu before pane/drawer), roving keyboard nav, focus-in/focus-return, BottomSheet swap at 768px, --bg-raised panel skin. - lib/components/common/MenuItem.svelte — icon/hint/danger/menuitemradio rows. - lib/utils/clickOutside.ts + lib/utils/portalAction.ts — extracted from the hand-copied per-menu versions. - app.css: --bg-raised token (dark = tertiary; light = white). Migrated: ItemActionsMenu (portal mode, entire hand-rolled machinery deleted), QuickActionsMenu (anchored + sheetOnMobile, EmojiPicker exemption via exempt(), BUG-2281 workarounds removed), TopBar user menu (desktop + mobile branches deduped into one snippet; gains aria-haspopup/expanded + keyboard nav it never had). E2E locators updated to accessible-name form. Documented leave-alones: TopBar workspace-overflow menu (it IS a dndzone — conditional mount / focus-steal / pointerdown-close each break drag-reorder; in-file comments), LaneActionsMenu drill-down + WorkspaceSwitcher (Phase 3 / later). Gates: svelte-check 0 errors, 488 tests, make check green; runtime-verified via Playwright: user menu roving nav (ArrowDown x2 -> Admin), ESC closes via stack, kebab portal placement + edge-aware rows. * fix(web): Menu review fixes — form focus hand-off, drag suppression, scroll-close without refocus, resize close Codex findings on #1022: (1) QuickActions create-form now receives focus when it swaps in (the focused MenuItem unmounts on the flip); (2) clickOutside gains suppress() and TopBar's user menu passes isDragging||dragArmed so pill drags can't slam it shut (parity with the old drag guard); (3) portal scroll/resize dismissal calls onclose() directly — no trigger refocus fighting the user's scroll (parity with the old returnFocus=false); (4) resize now also closes portal menus (stale fixed coords). |
||
|
|
d087c7d822 |
feat(web): PageHeader primitive + generic EmptyState — 15 pages adopted (TASK-2292) (#1021)
* feat(web): PageHeader primitive + generic EmptyState; adopt across 15 pages (TASK-2292)
PLAN-2290 Phase 2, PR 3. PageHeader (title/icon/count-pill/description/actions
snippet) replaces 9 per-page header scaffolds; EmptyState gains a generic mode
(icon/title/message/actions) alongside its legacy collection mode, adopted at
19 rogue .empty-state sites. Net -430 lines; dead scoped CSS deleted; two
pre-existing dead selectors and an unkeyed {#each} fixed en route.
Documented leave-alones: breadcrumb header on tags/[tag] (interactive
view-toggle), console section-level h2s (PageHeader is h1 — semantics),
connected-apps empty (inline <a> in copy; message prop is string-only).
Gates: svelte-check 0 errors (warnings 7->6), 488 web tests, make check green;
conventions/starred screenshots verified.
* fix(web): PageHeader rows wrap on narrow screens (Codex finding — restores the responsive behavior the deleted per-page mobile rules provided)
|
||
|
|
6422324edd |
feat(web): Button primitive + dark text-on-fill AA — 95 sites migrated (TASK-2292) (#1020)
* feat(web): Button primitive + dark text-on-fill AA fix; migrate 95 button sites (TASK-2292) PLAN-2290 Phase 2, PR 2. lib/components/common/Button.svelte — variants primary (filled --accent-primary-strong #7c4ff0, the violet band where white text passes AA 4.96:1 while staying >=3:1 vs surface — pays off the PR #1018 deferral) / secondary / ghost / danger (red tint, AA both themes); size sm/md; full attr passthrough (type=submit preserved at form sites). 95 usages across 14 files migrated (settings, conventions, playbooks x2, workspace home, console suite, modals, comment composer, EmptyState); dead scoped .btn* CSS deleted (net -291 lines). Deliberate leave-alones per file: ItemDetail action-bar strip (Phase 4 owns the pane), anchors styled as buttons, segmented controls, icon-only buttons, dashed low-emphasis affordances. Gates: svelte-check 0 errors (dead-selector warnings down 8->7), 488 web tests, make check green; screenshots reviewed both themes. * fix(web): Button class-prop merge + danger-solid variant for final confirms Codex findings on #1020: (1) caller-supplied class no longer clobbers the primitive's classes — class is destructured and merged, rest spread moved first; (2) new danger-solid variant (filled --accent-red-strong #dc2626, white text 4.83:1 AA both themes) restores destructive emphasis on the two final-confirm flows that had gone pale (OpenChildrenDialog override, conventions delete Confirm); entry-level destructive buttons keep the tint. |
||
|
|
a335033415 |
feat(web): Chip primitive + canonical fieldColors util — 48 badge sites migrated (TASK-2292) (#1019)
* feat(web): Chip primitive + canonical fieldColors util; migrate 48 badge sites (TASK-2292) PLAN-2290 Phase 2, PR 1. Extracts the first shared primitives: - lib/utils/fieldColors.ts — ONE statusColor/priorityColor (+ hasCanonicalStatus, formatFieldLabel), replacing four drifted implementations (ItemCard, fields/FieldEditor, CommandPalette, workspace home); shareView.ts re-exports it so public shares stay in lockstep. Deliberate unifications: open/new/todo/ planned -> --status-blue (was text-secondary on cards); active -> green (was cyan in palette/home); draft -> muted (was blue); rejected/cancelled/wontfix -> gray; priority medium -> text-secondary. - lib/components/common/Chip.svelte — tinted-pill primitive per the refresh mock (color-mix tint via new --chip-alpha token, colored text, dot/size/ onclick/pulse props); svelte-autofixer clean. - 48 badge usages across 15 files migrated to Chip; scoped .badge CSS deleted (net -355 lines). Deliberate leave-alones: GraphToolbar count bubble + filter toggles, stat tiles, timeline rail markers, avatars. Gates: svelte-check 0 errors, 488 web tests, make check green; board/settings screenshots verified in both themes. * fix(web): Chip button variant always preventDefaults (never navigates a parent <a>) Codex finding on #1019: an onclick Chip inside a link card would activate the link after the callback. preventDefault always (a chip is never a link); propagation intentionally continues so click-outside closers work — callers in interactive cards stopPropagation per the house pattern. |
||
|
|
841a2cb4ea |
feat(web): violet retheme — accent-primary alias, neutral scale, card tokens, radius, AA text (TASK-2291) (#1018)
* feat(web): violet retheme — accent-primary, neutral scale, card tokens, radius, AA text (TASK-2291) PLAN-2290 Phase 1, PR B. Values-only retheme in app.css + theme-color meta: - --accent-primary #9268f8 dark / #7c3aed light; --accent-blue aliased to it (~95% of its 462 sites are brand usage; categorical sites moved to --status-blue in PR A and stay blue). Dark value chosen by contrast math: AA as link text (4.85:1) while improving white-on-fill from 2.75 to 3.78 (>=3:1 UI threshold; full text-on-fill AA lands with the Phase 2 Button primitive via --text-on-accent). - Violet-biased neutral scale both themes; light mode inverts to off-white canvas (#f5f5f9) with white surfaces per the mock. - Muted/secondary text re-picked: >=5:1 on every bg token in both themes (closes TASK-2262 C9 app-wide). - --border-strong/--card-bg/--card-border/--shadow-card defined both themes (consumed from Phase 3). - Radius scale 6/4/8 -> 8/5/12; light-mode danger tuned to #dc2626 (4.83:1). - theme-color meta #4a9eff -> #8b5cf6. Verified: make check green; screenshots on 4 surfaces x 2 themes reviewed; contrast ratios computed for all text-token pairs. * fix(web): violet PWA branding (manifest/icon) + pin light accents in print block Codex review findings on #1018: manifest theme_color/background and icon.svg still carried the blue brand; print block now pins light-theme accents so dark-theme printing doesn't put the bright violet on white paper. Dark button text-on-fill AA is explicitly deferred to the Phase 2 Button primitive (tracked in TASK-2292). * fix(web): regenerate apple-touch-icon.png from the violet icon.svg (180x180) * fix(web): regenerate remaining brand rasters from violet icon.svg favicon-16/32, favicon.ico (single PNG-encoded 48px entry), icon-192 (was 0 bytes), icon-512, padicon.png (OG image, 701x701) all regenerated from the canonical icon.svg 'P' mark — the old rasters were a blue calendar design inconsistent with the linked SVG. site.webmanifest colors updated to the violet scheme. |
||
|
|
563371ee9b |
feat(web): define missing token families + zero-change drift sweep (TASK-2291) (#1017)
PLAN-2290 Phase 1, PR A. Defines --accent-red, --status-blue, --text-on-accent, --shadow-sm/md/lg, --modal-shadow, --scrim in app.css (values matching the long-standing inline fallbacks), then mechanically sweeps: - var(--accent-red, #hex) fallback forms collapsed (52+4+1 sites); 3 bare var(--accent-red) sites that previously resolved to NOTHING now render - phantom var(--color-danger, #dc2626) repointed to --accent-red - bare #ef4444/#dc2626 danger literals -> var(--accent-red) (57 files); #c0392b/#e53e3e/#dc2626 outliers unify to #ef4444 (deliberate) - shadow fallback forms collapsed to the now-defined tokens - 10 categorical literally-blue sites (status maps, burndown chart, info badge) repointed --accent-blue -> --status-blue so PR B's violet accent flip won't drag status colors Verified: svelte-check 0 errors, 488 web tests, make check green; Playwright before/after pixel diff on 4 surfaces x 2 themes — identical except the sidebar build-id string. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
dd79e837f6 |
fix(ci): narrow BUG-2278 web fix to advisory deps, revert toolchain slide (#999)
The broad `npm audit fix` from the previous commit ( |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
a0bb31d33b |
feat(web): mount the detail pane on the full-page item host (TASK-2174) (#980)
* feat(web): mount the detail pane on the full-page item host (TASK-2174)
PLAN-2154 Phase 2 / Architecture E, bullet 5 — the Q1 payoff. The full-page
item route (`[collection]/[slug]/+page.svelte`), previously a 47-line wrapper
that rendered `<ItemDetail>` full-page, now mounts the SAME right-docked detail
pane the collection page carries beside the master. From a full-page item,
clicking a child / related / wiki-linked item opens a navigable mini-browser
pane BESIDE the master — the master is the `[slug]` PATH param, the pane is the
`?item=` QUERY param, no collision.
- Reuse the shared `PaneHost` shell, `createPaneController`, and the `paneMint`
provider-mint settle. Collection-specific deps (j/k pane-follow,
list-row focus-return, quick-create draft guard) are correct no-ops here.
- Layout: a flex-row `.item-page-host` fills `.main-content` and clips, with an
`.item-page` overflow column as the master's own scroll container (always, so
scroll restoration is consistent) — mirrors the collection host's `.pane-open`
overflow handling. `createScrollRestoration` gets the `.item-page`
`scrollTarget` getter (TASK-2171); `export const snapshot` is kept.
- Freeze: `peeking={!!openItemRef}` makes the master retain-alive read-only
(TASK-2172) — never a provider teardown. `onIdentity` captures the master's
resolved {id,ref,slug} (TASK-2173).
- Master content-links FIRST-OPEN the pane (`openItemPaneByRef`, a depth-0
paneOwned:true push); pane content-links DRILL (`navigatePaneTo`). Both go
through the `?item == master` guard, plus a cold-load strip effect that drops
a hand-crafted `?item=<master>` once identity resolves — never a second collab
provider on the master's own room.
- Depth-aware ESC mirrors the collection host's escape-stack `pane` slot minus
the list-focus step: depth>0 pops one level (fenced `handlePaneBack`); depth 0
closes via the shared escape stack.
paneHostController: extract `openItemPane(item)`'s body into `openItemPaneByRef
(ref)` and make `openItemPane` a thin `openItemPaneByRef(itemUrlId(item))`
wrapper — behavior byte-identical, so the collection page's pane e2e stays green.
The full-page cross-collection BUG-2129 fence e2e (pane-collection-migration-
race) navigated A->B via a plain relationship-link click, which TASK-2174 now
intercepts to open a pane; adapt it to reach the same same-instance
cross-collection full-page nav via the pane's expand. Add pane-full-page-host
e2e for the new host (open/read-only-master/drill/back/close/self-ref strip).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): harden the full-page pane host self-guard + ESC (Codex round 1)
- P1 (race): gate the pane MOUNT on a resolved, confirmed-non-self identity
(`showPane`), not just the after-the-fact `?item=` strip. A cold
`?item=<master-alias>` no longer transiently mounts a 2nd collab provider on
the master's own room before the strip's `goto` fires. Click-driven opens are
unaffected (master identity already resolved); only a cold `?item=` load waits
one master-load beat (master-first).
- P2 (equivalence): make `isMaster` ref-NUMBER aware (case-insensitive prefix,
zero-padding), mirroring paneTarget's `matchesRefNumber`, so `doc-5` /
`DOC-005` / a stale pre-move prefix that resolve to the master are caught by
the self-guard + cold-load strip, not just the byte-exact canonical ref.
- P3 (ESC): defer the host's ESC when a MASTER (non-embedded) graph drawer is
open — it keeps its own window ESC listener, so one press must close only the
graph, not the graph AND the pane. Scoped to a `.graph-drawer` outside
`.item-pane` (the pane's own graph stays in the escape stack).
Add an e2e for the legitimate cold-load `?item=<other item>` path so the
mount-gate can't regress into suppressing valid cross-item cold loads.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): provenance-correct master guard + refined graph ESC (Codex round 2)
- P1 (provenance): the hand-rolled `isMaster` checked slug before ref-number,
but a bare `?item=` resolves REF-BEFORE-SLUG server-side — so a ref-shaped
SLUG (master #5 slugged `plan-6`) was misclassified as the master, wrongly
stripping/blocking a legitimate open of item #6. Reuse the shared, unit-tested
`isSamePaneTarget` / `resolvePaneTarget` instead: guards call
`resolvePaneTarget(target, masterItem)` (self-guard for free), and the
cold-load strip + mount gate call `isSamePaneTarget({ href }, masterItem)` (the
documented bare-`?item=` href channel, ref-before-slug). Narrow paneTarget's
`current` param to `PaneGuardItem = Pick<Item,'id'|'slug'|'item_number'>` so
the host can pass the master's resolved identity (item_number parsed from ref)
without fabricating a full Item; a full Item stays assignable, so every
existing caller is unchanged.
- P2 (ESC): only defer to the master graph's standalone listener when it's the
FRONTMOST ESC concern (no pane graph drawer at ESCAPE_PRIORITY.graphDrawer is
the top stack layer). When a pane graph IS the top layer it's innermost and
must close first via runTopEscape.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): pane-host ESC layering, block-drag scroll, print (Codex round 3)
- Dual-graph ESC: when a master graph AND a pane graph are both open, the host
closes the innermost pane graph via runTopEscape, but the master graph's
uncoordinated window listener ignores preventDefault. Add a scoped
`stopImmediatePropagation()` (only when a master graph exists) so one ESC
closes exactly one layer; the host's window listener registers before the
master graph's, so it runs first.
- Block-drag auto-scroll: `block-drag-handle.ts` hard-coded `.main-content` as
the scroll container, which no longer scrolls beneath the full-page host's
`.item-page` column (and never matched the docked pane's `.item-pane`). Walk
to the innermost actually-scrollable ancestor by computed overflow-y instead —
class-agnostic (the host's scroll column shares `.item-page` with ItemDetail's
non-scrolling inner wrapper) and also fixes the pre-existing pane-editor case.
- Print: hide the docked pane + divider and un-clip the flex-row host so
Ctrl/Cmd+P captures only the master document (not two items side-by-side with
duplicate footers), mirroring app.css's shell/main-content print unlock which
doesn't reach these route-owned containers.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): suppress master-graph listener on held-ESC repeats (Codex round 4)
In the dual-graph case (master graph + pane graph both open), a HELD ESC's
auto-repeats reached the master-graph deferral (or exited the repeat branch)
without stopImmediatePropagation, so the master graph's uncoordinated window
listener (which doesn't bail on e.repeat) closed the second graph during the
same held press. Move the repeat bail before the master-graph deferral and
stopImmediatePropagation on repeats when a master graph coexists, so one
press/hold closes exactly one layer.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): gate master identity on ref freshness (Codex round 5)
masterIdentity (from onIdentity) LAGS a same-route master navigation: expanding
a pane item to the full page, then browser-Back to <prev-master>?item=<that
item>, reuses this route, so masterIdentity still holds the expanded item until
the master reloads the previous one. The self-guard/strip then compared the bare
?item= against the WRONG (previous) master and stripped a still-valid pane
(Expand->Back lost the pane). Fold a fresh-for-`ref` check into masterItem (a
$derived over `ref`, so it re-nulls synchronously the instant `ref` changes,
before the strip $effect runs) via the shared isSamePaneTarget; base showPane +
the strip on masterItem instead of raw masterIdentity. Add an Expand->Back
restoration e2e.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): route master graph through escape stack + pathname master freshness (Codex round 6)
- Master-graph ESC (converging fix): ItemDetail's full-page (non-embedded) graph
drawer kept its OWN window ESC listener on the now-false assumption of "no
pane/list contention on the full-page route". The full-page pane host adds a
pane beside a non-embedded master, and that uncoordinated listener double-closed
the master graph across ESC paths (dual-graph, held-key, text-edit/dialog
bails). Route BOTH embedded + non-embedded graphs through the shared escape
stack (ESCAPE_PRIORITY.graphDrawer); the host's single runTopEscape then closes
exactly one layer, innermost-first. Simplifies the host ESC handler (drops the
DOM-scan / stopImmediatePropagation whack-a-mole). Contained: embedded=false
ItemDetail is only the [slug] route, which owns that top-level listener; the
collection page's pane graph was already stack-based (unchanged).
- Master freshness (Finding 1, incl. cross-workspace): gate masterItem on a
pathname-stamp comparison instead of a ref-only check. onIdentity stamps the
pathname it resolved for; masterItem ($derived over page.url.pathname) drops a
stale identity the instant the pathname diverges — covering ref, collection AND
workspace navigations uniformly (a bare ?item= can no longer match a prior
master that merely shares a path ref across workspaces).
Deferred BUG-2178 (child of PLAN-2154): the pane's collection-rename/move
navigate-away emits collection-host-shaped URLs on the full-page host — a
separable, obscure refinement.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): close ref-shaped-slug guard hole, UUID identity, bind scroll target (orchestrator round 8)
- P1 (ref-shaped-slug HOLE): the server resolves a bare/href ?item= REF-FIRST
then falls back to SLUG (internal/store/items.go), so a master #5 slugged
`plan-6` (no live #6) is reached by the slug fallback — but isMasterRef's
ref-number channel alone returned false (6!=5), WRONGLY permitting a 2nd
provider on the master's own collab room. isMasterRef now matches the raw
string against the master's id/slug too (either interpretation → master); the
guards also drop a resolved content-link ref that string-equals the master
slug (closes the href-only editor-link path). Erring toward a match is the safe
direction for the forbidden D2 collision. Add a ref-shaped-slug cold-load-strip
e2e (seedDoc "plan" → ref-shaped slug plan-<ts>).
- P2 (UUID master routes): itemMatchesRef (ItemDetail) accepted only slug or
PREFIX-NUMBER, so a UUID route never fired onIdentity/onReady → the guard +
scroll-readiness silently disabled there. Add the item.id === itemSlug arm
(additive; also fixes scroll restoration on UUID routes).
- P2 (scroll-target fragility): bind the outer .item-page column element and pass
it to createScrollRestoration instead of document.querySelector('.item-page')
(which also matches ItemDetail's inner wrapper of the same class) — no global
selector, no collision.
BUG-2178 (collection-rename/move-from-pane URL shaping) remains the deferred,
non-destructive follow-up.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): mirror server UUID-first precedence in the master guard (round 8 verify)
The raw `candidate === masterItem.slug` check over-blocked a legitimate
`?item=<another item's UUID>` when the master's slug coincidentally equalled
that UUID (the server resolves UUID-FIRST, so it opens the other item, not the
master). isMasterRef now mirrors the server precedence by shape: exact id →
master; a UUID-SHAPED candidate that isn't the master's id → NOT master (don't
fall through to slug); otherwise ref-number OR raw-slug match (still catches the
ref-shaped-slug slug-fallback case). Add an e2e where a master's slug IS another
item's UUID and `?item=<that UUID>` correctly opens the other item.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): server-confirm the ambiguous ref/UUID-shaped-slug master guard (round 9 verify)
Shape alone can't decide whether a ref/UUID-shaped `?item=` that string-equals
the master's slug is preempted by a live item (UUID/ref-first) or reached by the
server's slug-fallback to the master (absent/archived/cross-workspace item) —
the over-block (round 8) and under-block (round 9) are the two horns of a
client-side-undecidable problem. Resolve it definitively: masterMatchSync now
returns true/false/NULL, and the NULL (uncertain) case — reached ONLY for a
pathological ref/UUID-shaped master slug, never for a normal plain-slug master,
so zero network cost in normal use — is confirmed by resolving `?item=` to its
actual item id via api.items.get and comparing to the master's id. The mount
gate blocks while that resolve is pending (never a 2nd provider on the master's
room before we know); the strip waits for a definite master (never deletes a
`?item=` that might still resolve elsewhere); the content-link guards drop only
a definite-master resolved segment. On resolve error, err toward blocking.
Both edge tests (ref-shaped-slug strip, UUID-slug over-block) now pass through
the server-confirm path.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): key-associate + de-wedge the server-confirm, drop guard over-block (round 10 verify)
Three fixes to the round-9 server-confirm machinery:
- P1 (wedge): the cancelled-fetch dedup left lastConfirmKey pointing at the
abandoned key, so revisiting it early-returned with no result — URL blocked,
no pane, no strip, forever. Replace with an inFlightKey plain-let cleared on
cancel AND completion, so a cancelled key re-fetches.
- P1 (stale result): confirmedTargetIsMaster was a bare boolean not tied to its
target, so a prior key's answer could transiently leak to a new uncertain
target (mount/re-target before confirmation). Store {key, isMaster} and treat
a key-mismatch as pending (confirmedFor). The effect no longer READS the
reactive result it writes (dedup is the plain-let inFlightKey) — no CONVE-1688
read-write cycle.
- P2 (guard over-block): the content-link guards used resolvePaneTarget(target,
masterItem), whose isSamePaneTarget same-item guard over-blocks the same
UUID-shaped-slug case (a UUID href == master.slug treated as self even for a
different live item). Resolve without the current-guard and drop only a
DEFINITE master alias (masterMatchSync === true); an uncertain alias defers to
the mount gate's server-confirm.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): revert to the conservative sync master guard (D2 err-toward-match)
The rounds 9-10 async server-confirm — added to distinguish the two
genuinely-undecidable-by-client cases of a ref/UUID-SHAPED master SLUG
(preempts to a different live item vs falls back to the master) — proved a
poor tradeoff: it introduced real race surface (stale-cache-across-archive, a
popstate/mint-settle remount race, SSE-driven refetch) and a shared-code
fireOpenTarget interaction, all for a scenario that requires a PATHOLOGICAL
master slug (one shaped like a ref or a UUID, never produced by an organic
title).
Revert to the simple, conservative sync guard the orchestrator's finding #1
explicitly directed ("return true whenever the target resolves to the master
under EITHER the ref or the slug interpretation ... erring toward a match is the
safe direction"): isMasterRef matches id OR raw-slug OR ref-number. This is
SAFE — it never allows the forbidden D2 collision (a 2nd provider on the
master's room). Its only cost is a BENIGN over-block in exactly the pathological
case (a `?item=<live different item>` whose string coincidentally equals the
master's ref/UUID-shaped slug declines to open a pane — no collision, no data
loss), which matches the existing shared isSamePaneTarget / fireOpenTarget
behavior. Remove the async apparatus + api import + the UUID-slug precision e2e
(the ref-shaped-slug strip test still covers the conservative slug-match block).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
48dfbe0c7b |
feat(web): expose ItemDetail resolved {id,ref,slug} identity via onIdentity (TASK-2173) (#979)
PLAN-2154 Phase 2 (Architecture E, bullet 4). Beyond the onReady boolean,
ItemDetail now emits its LOADED item's resolved {id, ref, slug} — or null
while unresolved — through a new optional `onIdentity` callback, fired on the
same switch boundary as onReady (itemMatchesRef). A host uses it to enforce
the `?item == master` guard even when a slug-path and a ref-query alias the
same item; the full-page pane host (TASK-2174) compares its `?item=` target
against all three fields before mounting a pane.
New shared `ResolvedItemIdentity` type in $lib/types. Purely additive —
existing callers that don't pass onIdentity are byte-identical. The resolved
identity is a $derived only READ by the emit effect (CONVE-1688-safe).
Behavioral coverage lands with the host guard in TASK-2174/2175.
Verified: npm run check 0 errors.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
acd9f3ff1b |
feat(web): retain-alive master freeze — peeking prop + complete mutation-path audit (TASK-2172) (#977)
PLAN-2154 Phase 2 (Architecture E, D2/R6/R9/R11/R12), per Dave's HT-2176 Option A.
Adds a `peeking` prop to `ItemDetail` so the full-page master goes read-only +
ALIVE while a detail pane peeks beside it — retain-alive: the collab
PROVIDER/Y.Doc is never torn down/flushed. `peeking` defaults false → every
existing caller byte-identical (no host passes peeking={true} until TASK-2174).
Invariant (HT-2176 Option A): the freeze blocks only the INITIATION of a NEW
edit while peeking; a save/action the user STARTED before the pane opened
completes normally. Acceptance: "no NEW user-originated edit can be INITIATED
while peeking" (not "zero dispatch").
Core freeze (blocks NEW-action initiation): `mutationsEnabled = canEdit && !peeking`
(unit-tested `computeMutationsEnabled`) gates title / fields / tags / assign /
move / delete / relationship-add; ChildItems add/link + reorder INITIATION;
timeline comment compose + edit forms + upload; version-restore initiation;
archived restore; star (`!peeking`); Share + the whole quick-actions menu
(`!peeking`). The rich Editor gets `editable={!peeking}` + `peeking` in the
`{#key}` (remounts re-bound to the SAME live Y.Doc, shedding BlockDragHandle —
D2's mechanism); bubble/link popovers gated.
Pre-pane in-flight SAVES/ACTIONS complete (Option A) — no suppression:
- FIELD: `updateField` has NO peeking recheck; a value typed before the pane
opened fires its 500ms FieldEditor debounce and saves. NEW field input is
blocked at the UI (`FieldEditor readonly={!mutationsEnabled}`).
- TAG / RAW / COLLAB debounced saves complete (drain/save callbacks un-gated).
- CHILD REORDER: gated at INITIATION (`dragDisabled: !canEdit || frozen`), NOT
finalization — a drag already in progress when the pane opens persists fully
(removed the `frozen` reject from `handleFinalize`).
Documented deliberate exceptions/edges:
- Rich⇄Markdown mode FLIP is refused while peeking (it would teardown/recreate
the collab provider — retain-alive/D2); the CONTENT still flushes, only the
flip is deferred. Commented at both toggle handlers.
- BUG-2177 (accepted, tracked, sev=low): the D2 `{#key}` editor remount destroys
the initiating view, so an in-flight EDITOR-BOUND action (attachment upload,
rotate/crop, source-refresh, timeline-composer upload) is orphaned — bails
gracefully via `view.isDestroyed`/identity checks (no crash, no committed-
content loss). Referenced at every bail site.
Confirmed no teardown: `createCollabFlusher`'s save is only
`api.items.flushCollabContent`; `provider.destroy` lives solely in the
`{#key}`/forceRefreshNonce $effect cleanup.
R12 complete editor-DOM-handler audit — every custom handler that dispatches a
mutation is editable-gated (AttachmentUpload paste/drop/command; AttachmentImage
rotate/crop/toolbar; code-block + table clipboard CUT/PASTE — copy read-only;
htmlBlock edit-mode). These also close the same latent holes for view-only
viewers (already-broken mutations) — a bug fix, not a working-flow regression.
R9 clear-if-owner activeItem via `activeItemOwnedId` (byte-identical on a
cross-collection load failure) + re-assert on un-peek; R11 master title effect
yields while peeking. Timeline freeze via a `frozen` prop threaded ItemTimeline →
TimelineCommentCard / TimelineVersionCard. Tests: `computeMutationsEnabled` truth
table + FreezeProbe gate probe + a FieldSaveProbe mount test proving a pre-pane
debounced field save completes after the field goes read-only. Running-app
mutation-silence e2e is TASK-2175 (F). Not a Y.Doc node-spec change —
SCHEMA_VERSION/DefaultSchemaVersion unchanged.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
04923c1c56 |
refactor(web): extract the pane controller + shell into paneHostController + PaneHost (TASK-2170) (#978)
* refactor(web): extract pane controller glue into paneHostController (TASK-2170) PLAN-2154 Phase 2 (Architecture E) increment 1 of 2. Move the pane- navigation controller — the depth/ownership state machine, the fenced `history.go` traversals + their `afterNavigate` settle, and the three-way staged close — out of the 4805-line collection route into a shared `$lib/collections/paneHostController` factory, so the collection page and (in TASK-2174) the full-page item host mount ONE controller with no duplicated arithmetic. The pure decision logic already lived in `paneController.ts` (unit-tested); this extracts the SvelteKit-bound glue that executes those plans. The five collection-specific hooks are injected: getOpenItemRef, cancelFollow (j/k pane-follow), focusPaneRegion (owned by the shell), captureReturnFocus (list-row focus return), and setBypassNavGuard (unsaved-draft leave guard). The route destructures the controller with the SAME names its markup, handlers, and test hook already use, so no call sites changed; the depth- aware ESC pop now routes through the public `handlePaneBack` primitive instead of the now-internal `paneHistoryGo`. Behavior-preserving: `npm run check` 0 errors, vitest 435 passed, pane e2e 49/49 (matches the pre-extraction baseline). Increment 2 extracts the <aside> shell + resize/focus into PaneHost.svelte. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * refactor(web): extract the pane shell into PaneHost.svelte (TASK-2170) Increment 2/2 of PLAN-2154 Phase 2 (Architecture E, bullet 1). Pull the right-docked detail-pane <aside> SHELL out of the 4805-line collection route into a new reusable web/src/lib/components/collections/PaneHost.svelte so both the collection page and (in TASK-2174) the full-page item host mount ONE shell. Moved into PaneHost: the .pane-divider + <aside class="item-pane"> markup and its {#if paneMintForRoute}<ItemDetail embedded .../> render; the resize machinery (paneEl/paneWidth/storedPaneWidth/measuredPaneWidth/ariaMin/ariaMax state, PANE_WIDTH_* consts, paneBounds/clampPaneWidth/readStoredPaneWidth/ setPaneWidth/fittedPaneWidth/refitPaneWidth/currentPaneWidth, the ResizeObserver effect, the divider pointer/keyboard handlers); the focus machinery (focusPaneRegion, the mobile focus-into effect, the mobile focus-trap effect, the desktop focusin backstop); and the .item-pane / .pane-divider / mobile @media CSS. The mid-drag body-chrome cleanup that the route ran from an openItemRef→null effect is now a PaneHost onDestroy — the shell's unmount IS that transition. Stays in the route (unchanged): the {#if openItemRef}<PaneHost/>{/if} gate, paneMint (paneMintRef/paneMintPathname/paneMintForRoute + createPaneMintSettle + its afterNavigate + onDestroy cancel — its always-mounted lifecycle is load-bearing for TASK-2166 popstate coalescing, so the route computes paneMintForRoute and passes it as a PROP), createPaneController, schedulePaneFollow/cancelPaneFollow, focusedItemId + pane-snap, returnFocusToList/resolvePaneReturnTarget, dismissPaneOnBackgroundClick, handlePageKeydown. Seam: PaneHost exposes focusPaneRegion via a Svelte 5 instance export; the route binds bind:this={paneHostEl} and rewires both the controller's focusPaneRegion dep and the list→pane Tab bridge to paneHostEl?.focusPaneRegion(). Behavior-preserving refactor — the load-bearing no-{#key} invariant is intact (an A→B switch stays a ref PROP UPDATE reusing the one mounted ItemDetail instance + its single collab provider + SSE sub; the only mount/unmount is the outer {#if openItemRef}). Gates: svelte-check 0 errors; vitest 435 passed; pane e2e 49 passed / 49 skipped / 0 failed (the pre-extraction baseline). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
11c7e1e98c |
feat(web): parameterize scroll-target in createScrollRestoration (TASK-2171) (#976)
* feat(web): parameterize scroll-target in createScrollRestoration (TASK-2171) Add an optional `scrollTarget` getter to `ScrollRestorationOptions` and route both the capture and restore-loop call sites through a local `resolveScrollTarget = opts.scrollTarget ?? getScrollTarget`. When omitted (all 12 existing callers), behavior is byte-identical — the module default still resolves `.main-content`, falling back to `window`. This unblocks PLAN-2154 Phase 2 (Architecture E): the full-page item host scrolls its own `.item-page` overflow column once a detail pane docks beside it, and will pass that container via the new getter in TASK-2174. Verified: `npm run check` (0 errors), `make install` web+go build green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): retry scroll restore when a custom scrollTarget is not yet mounted (TASK-2171) Codex review caught a doc/code gap: the scrollTarget getter's documented per-frame-retry contract wasn't delivered — the tryScroll loop aborted permanently when resolveScrollTarget() returned null, and since restoredKey is already set for the key, the gated effect never re-fires, stranding the parked offset. Now a null target re-schedules the RAF within the existing 2s budget until the element mounts. The default .main-content/window resolver never returns null in-browser, so existing callers are unaffected. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
2aff33d99c |
test(web): R14 async-race capstone e2e suite for the pane mini-browser (TASK-2167) (#975)
The R14 fence-on-continuation sweep of every pane-controller async
continuation site (schedulePaneFollow debounce, paneHistoryGo + afterNavigate
latches, the popstate mint-settle, and ItemDetail's loadGeneration-gated
loadData/SSE/save continuations) confirmed all sites are already fenced by
TASK-2155/2156/2157/2161/2162/2166 — this adds the capstone suite that
exercises those fences end-to-end under adversarial timing so a regression at
any one fails loudly.
Eight tests, each asserting the R14 invariant (no late continuation writes
stale state over a newer item — no ?item= clobber, no depth desync, no crossed
content, no stale field write-back), made deterministic and non-vacuous
(route-gated stale requests proven to enter the gate and drain; fenced-out
actions proven to issue no fetch; crossed-Y.Doc caught at the editor-body
level). Each timing test runs in its OWN fresh collection so its list is
deterministic and it doesn't bloat the shared docs list:
1. a late j/k follow surviving a popstate-forward drill — isolates the
continuation-time depth RE-CHECK from the cancellation belt (the follow
target is held distinct from the forward target so the same-item guard
cannot be what saves it);
2. a drill fired right after a browser Back, mid mint-settle;
3. a close + drill fired during an in-flight cold-base close traversal + latch
(proves the fenced-out drill target is never fetched);
4. a rapid ?item= re-target while a route-gated loadData GET is outstanding
(title + field + editor body all stay on the newer item);
5. a deep browser Back/Forward traversal through a drilled stack (editor-body
locked per hop);
5b. a genuine multi-popstate Back BURST (each back chained off the preceding
popstate; 3 events asserted) coalescing via the mint-settle;
6. a cross-collection Back onto an open pane — paneMintForRoute route-reuse
clamp, proven by the fetch sequence (destination item fetched, stale source
never re-fetched);
7. a loadData GET resolving after the pane closed and a new item opened writes
nothing over the new item.
No production code changes: the audit found no unfenced continuation to close.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
57da084a32 |
feat(web): focus per hop for the detail-pane mini-browser (TASK-2162) (#972)
* feat(web): focus per hop for the detail-pane mini-browser (TASK-2162)
PLAN-2154 Architecture C / R1. Each in-pane hop (a content-link drill or
an in-pane Back) changes `?item=`, remounting ItemDetail's `{#key itemSlug}`
subtrees and destroying the just-activated link/row; `keepFocus` then drops
focus to `<body>`, where the next `j`/`k` runs list-nav and can laterally
re-target the drilled `?item=`, corrupting the stack.
Host now owns focus-per-hop: `focusPaneRegion` moves focus onto the STABLE
aria-labeled `<aside>` synchronously at each drill (`navigatePaneTo`) and
Back-button pop (`handlePaneBack`), before the imminent remount can drop it.
This covers the editor-link keyboard path (EditorLinkPopover hides its
popover — removing the focused anchor — synchronously) since it funnels
through the same `navigatePaneTo` chokepoint. A narrowed desktop `focusin`
backstop mirrors the mobile trap, re-pulling focus into the pane only when
it drops to `<body>` from within `.item-pane` (the list stays reachable).
The depth-aware ESC pop removes no focused control, so it deliberately does
not force focus into the pane — that would fight the depth-0 return-to-list.
Resolves the focus edge TASK-2164 deferred here: its component-local
armed-`pendingBackFocus` restore (which could mis-fire onto a pane control
on a coalesced Back-then-Forward no-op) is retired in favor of this
deterministic host-owned focus — the explicit host resolution signal
TASK-2164 round 10 identified as the real fix. `onBack` is now a pure notify.
Tests: focus-per-hop e2e (mouse + keyboard drill land focus in the pane
mid-load with the destination gated so only `focusPaneRegion` could have done
it; j stays inert; in-pane Back keeps focus in the pane). TASK-2164's
Back-chevron focus assertions reconciled to assert focus-in-pane (the actual
acceptance criterion) rather than a specific button. The depth-0 ESC test now
asserts the standard two-level return-to-list-then-close, which focus-per-hop
correctly restores (drilling no longer strands focus on `<body>`).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): run focus-per-hop on mobile + the ESC pop (Codex round 1)
Two P1s from review:
- focusPaneRegion no-op'd on mobile while pendingBackFocus (which was NOT
desktop-gated) had been removed, so a mobile Back/drill could strand focus
on <body> behind the overlay if the mobile trap's focusin missed the
removal. Focus the stable paneEl on mobile too — a synchronous belt that
supersedes what pendingBackFocus gave the mobile Back path (j/k is already
inert on the overlay, so this is purely the a11y "don't lose focus" belt).
- The depth-aware ESC pop skipped focusPaneRegion; if the user had Tabbed to
a control the pop then removes (e.g. the Back chevron via the header swap),
focus could strand on <body>. Land focus on the stable paneEl at the pop.
It targets paneEl (never a removed control) so it needs no focusin, and it
doesn't fight the depth-0 two-level ESC — that's a separate later press that
correctly returns focus to the list from within the pane.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
a0299321ca |
feat(web): in-pane Back chevron for the pane chrome (TASK-2164) (#970)
* feat(web): in-pane Back chevron for the pane chrome (TASK-2164) Render a Back chevron in the pane header (ItemDetail's embedded-chrome branch) whenever `page.state.paneDepth > 0` (PLAN-2154 Architecture C). Wired to the collection host's existing fenced `paneHistoryGo(-1)` / `paneNavInFlight()` traversal — the same mechanism the depth-aware ESC handler (TASK-2163) uses — rather than a bare `history.back()`, so a rapid double-click can't stack a second traversal (R14). Depth is read reactively via SvelteKit's `page.state` accessor inside ItemDetail itself, so a cold-loaded shared `?item=` correctly starts at depth 0 with the chevron hidden. Shared markup covers desktop and the mobile full-screen overlay alike. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): keep the Back chevron reachable in the pane's minimal header Every drill sets loading=true, which switches ItemDetail to the minimal embedded header (previously Close-only) — so a slow or failed drilled item stranded the user with no way back, worst on mobile where ESC isn't reachable. Mirror the same depth-gated chevron there (Codex review round 1). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): restore focus to the Back chevron after each pop (Codex round 2) Clicking Back triggers a reload of the drilled-to item, which briefly sets loading=true and swaps the loaded pane header for the minimal one — unmounting the just-clicked, focused Back button and dropping focus to <body>. Without this, a keyboard user popping a multi-level drill stack loses their place after every press. Track the intent with two flags (not one): `paneDepth` updates synchronously with the popstate `history.go(-1)` fires, landing before `itemSlug` changes and `loadData()` actually sets `loading = true` — a single-flag effect can observe a stale `loading === false` on that intermediate run and consume the flag before the real reload cycle even starts. `backFocusSeenLoading` requires observing `loading` go true first, so the restore only fires on the matching false after it. Also fixes a race in the new multi-hop e2e test: `drillTo()` only awaits `navigatePaneTo`'s synchronous portion (the `goto()` it fires is fire-and-forget), so firing three drills back-to-back without polling between them could read stale depth. Poll after each hop, and assert the Back button keeps focus across presses. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): key Back-chevron focus restore off itemSlug, not a loading transition (Codex round 3) Codex round 3 found a real race in the loading-transition-based focus restore: pressing Back again from the minimal header — while a PRIOR item is still loading (paneNavInFlight() only fences the history.go traversal, not the data fetch) — could let the effect latch onto that stale, already-in-flight cycle instead of its own. Investigating with instrumentation showed the generation-fenced version of the fix was actually correct in isolation, but the added console logging was itself perturbing timing enough to mask a genuinely flaky window under heavier parallel load. Replaced the two-flag, generation-counting design with a much simpler one: key off itemSlug (purely URL-derived, updates synchronously with the same popstate that stamps paneDepth, independent of fetch status) actually changing away from the ref captured at click time, then wait for loading to clear. No transition-observation bookkeeping needed. Verified with 25+ consecutive passes of the new race-condition e2e test across workers=1/2/4, including alongside the full pane suite under system load from concurrent sibling agents. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): fall back to the Close button after the terminal Back pop (Codex round 4) The last Back press (landing at depth 0) has no Back button left to restore focus to, so it was stranding focus on <body> with the pane still open. Fall back to the always-present Close button — a stable, keyboard-reachable control — rather than leaving it unhandled. Full "focus per hop" for arbitrary content-link drills stays TASK-2162's scope; this only closes the gap the Back button's own terminal press opened. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): bind the minimal header's Close button too (Codex round 5) closeBtnEl (the terminal-Back-pop focus fallback added in the previous commit) was only bound on the loaded header. If the pop's destination is slow or fails to load, the minimal header can still be mounted when the restore effect fires, leaving closeBtnEl undefined and the restore a silent no-op. Bind it on both headers, mirroring backBtnEl's existing pattern. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): fence Back-chevron focus restore against a superseding navigation (Codex round 6) Comparing only itemSlug treated ANY later navigation as "the Back pop landed" — so clicking a different row (or hitting Forward) while the Back destination is still loading would let that unrelated navigation's itemSlug change satisfy the check, stealing focus into a pane the user never asked to restore-focus on. Also left the pending flag armed indefinitely across a quick Back->Forward that never matched. Fence with loadGeneration (bumped synchronously at the top of every loadData() call, regardless of trigger): capture it at click time and require the settled generation to be EXACTLY one more — the Back click's own load and nothing else raced in ahead of it. A mismatch abandons the restore instead of stealing focus, and self-resolves the "armed indefinitely" case since loadGeneration only increases. Verified the new regression test actually catches the bug: temporarily disabled the guard, confirmed the test fails (focus lands on Close), then restored it and confirmed it passes. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): bound the Back-chevron focus-restore intent with a timeout (Codex round 7) The generation fence alone didn't self-resolve every "superseded" case: a Back immediately reversed by Forward within TASK-2166's ~140ms pane-mint settle window can coalesce to a net no-op — neither itemSlug nor loadGeneration ever change — leaving pendingBackFocus armed with nothing left to ever falsify the generation check against. The next, wholly unrelated single-load navigation would then satisfy backFocusStartGen + 1 by coincidence and steal focus. Rather than chase each interleaving individually, bound the pending intent with a timer (mirroring the host's own PANE_GO_SETTLE_MS "give up waiting" pattern): if the click's own restore hasn't resolved within 600ms, disarm unconditionally. Cleared on both the normal resolve path and onDestroy. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): replace the blunt Back-focus timeout with a targeted no-op check (Codex round 8) Round 7's flat 600ms wall-clock timeout disarmed the pending restore unconditionally, which also cancelled a legitimately slow, still- in-flight restore — loadData() makes several requests, and a modestly slow connection can easily outrun a few hundred ms, stranding a keyboard user's Back press on <body> for the common case, not just the rare coalesced-no-op one. Replace it with a single check shortly after TASK-2166's ~140ms pane-mint settle window: itemSlug updates synchronously with the popstate (independent of network speed) and loadGeneration bumps synchronously at the top of loadData(), so a load that's genuinely in flight has already moved one of the two by the time the check fires. Only the coalesced-to-nothing case (Back immediately reversed by Forward) still shows both unchanged — that's the only case the check disarms. Every other pending restore, however slow, resolves normally through the existing effect. Added a dedicated regression test proving a 400ms-delayed (well past the 200ms check) but legitimate destination load still restores focus correctly. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): correct the Back-focus no-op check window against TASK-2166's mint settle (Codex round 9) An earlier comment claimed itemSlug updates synchronously with the popstate onBack triggers — true before this branch merged TASK-2166, false after: that sibling change added a provider-mint settle that deliberately coalesces EVERY popstate-driven ?item= change onto the <ItemDetail> ref prop (even a single Back press) behind a PANE_MINT_SETTLE_MS (140ms) window, so itemSlug only starts moving ~140ms plus the history.go->popstate round-trip after the click, regardless of network speed. The 200ms no-op-check window from round 8 started at click time, so any traversal taking as little as ~60ms beyond the mandatory 140ms settle could clear pendingBackFocus as a false no-op, stranding a keyboard user's Back press on <body>. Import PANE_MINT_SETTLE_MS from the authoritative source and size the check at 2x it for headroom over the popstate round-trip on top of the settle itself. Also consolidated the accumulated round-by-round comment trail into a single numbered summary plus one timing note, since the old inline diary had an outdated claim baked into it. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): drop the timer-based Back-focus no-op heuristic (Codex round 10) Three review rounds (7-9) tried progressively longer wall-clock "give up waiting" timeouts to disarm a stale pending focus-restore on a coalesced Back-then-Forward no-op. Each one got shown to be racing an ever-longer worst-case bound elsewhere in the pipeline: the mint settle, then the settle plus the popstate round-trip, then paneHistoryGo's own 500ms fallback on top of both. A fixed-duration heuristic is structurally the wrong tool for this — closing it for real needs an explicit "did this specific click's traversal resolve" signal from the host, which is new cross-component plumbing, not a hardening pass on the existing effect. Drop the timer. Keep the generation fence (timing-independent, and the part that closes the actual bug class Codex round 6 found: an unrelated navigation superseding a stalled Back pop). Document the residual gap as a known, narrow, low-severity limitation for TASK-2162 (the already-planned general "focus per hop" follow-up) instead of continuing to patch it here: worst case, focus lands on a nearby, still-sensible, keyboard-reachable pane control instead of the ideal target — not lost, not a correctness or data issue, and it requires a fairly deliberate Back-then-Forward sequence to trigger at all. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
b37797edfc |
feat(web): settle popstate-driven pane mints to one teardown/mint (TASK-2166) (#971)
* feat(web): settle popstate-driven pane mints to one teardown/mint (TASK-2166) A held Back/Forward fires one popstate per history entry traversed, and each `?item=` change re-drives the split pane's collab provider (destroy + fresh Y.Doc/WS + op-log replay). j/k pane-follow already coalesces via PANE_FOLLOW_DEBOUNCE_MS, but popstate bypassed it entirely. Add paneMintSettle (pure, unit-tested) to settle popstate-driven `?item=` changes ~140ms before feeding ItemDetail's `ref` prop, while a deliberate open/drill/close still applies immediately. Audited the rest of PLAN-2154 Architecture D: loadUrlFilters' known-params whitelist, updateUrlFilters' `?item=`/page.state re-emit, and the `!openItemRef` title gate were already correct (TASK-2157/2158). The pane's scroll container deliberately skips the route-level persistKey/snapshot system entirely (PLAN-2105), so there's no persistKey collision to fix. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): guard pane-mint settle against cross-route reuse and null refs Two Codex-review findings on the popstate settle (TASK-2166): 1. This route component is reused across a workspace/collection switch (same route id, different page.params) — wsSlug/collSlug update immediately while a coalesced ref could linger from the SOURCE workspace for up to the settle window. Gate coalescing on a same-pathname popstate only; any pathname change forces the settle module's immediate branch. 2. The `paneMintRef ?? openItemRef` fallback silently bypassed the settle whenever paneMintRef was null (pane opening from closed via a Forward burst), passing every intermediate ref straight through. Nest `<ItemDetail>`'s mount on `paneMintRef` itself (ref: string is required, so no fallback) instead of falling back to the live URL value. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): never delay a pane-close settle (Codex review) A popstate landing on ref=null (the pane closing) now applies immediately instead of joining the settle window. The pane's mount boundary (`{#if openItemRef}`) already reacts to the raw URL instantly, so a delayed null left a stale non-null paneMintRef around: a quick Back-to-close-then-Forward-to-reopen within the settle window would remount ItemDetail against the pre-close ref before the real settle caught up — wrong content plus a wasted extra mint. Adds a close-then- reopen burst regression test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): close the afterNavigate-timing gap on cross-route pane mints Codex review (PR #971 round 2): afterNavigate fires AFTER SvelteKit has already committed page.params/page.url and Svelte has re-rendered the $derived tree over them (wsSlug, collSlug, ItemDetail's props). Correcting paneMintRef only from afterNavigate therefore still let one render pass through pairing the DESTINATION workspace/collection against the STALE SOURCE item ref on a workspace/collection switch (this route component is reused across such switches). Add paneMintForRoute, a $derived clamp that recomputes SYNCHRONOUSLY in the same reactive pass as page.url.pathname itself: it falls back to the live, always-route-consistent openItemRef the instant the pathname diverges from paneMintPathname (the plain var afterNavigate corrects, now just a hint rather than the sole source of truth). A same-pathname settle-in-progress — the actual pane-burst case this feature targets — is unaffected; the clamp is a pure no-op whenever the pathname hasn't changed. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
c9564713e1 |
feat(web): un-hide structural parent breadcrumb in the embedded pane (TASK-2165) (#969)
Single parent hop for v1 (PLAN-2154 Architecture C / D3): the embedded pane now renders Home / parent-collection / parent / current, built entirely from the item's own parent_ref/parent_slug/parent_collection_slug fields (zero new fetches), so it's correct even on a cold-loaded shared ?item= pane URL. The parent hop routes through the pane-navigate callback (paneOpenTarget) with href preserved for modifier/middle-click popout. Extracted breadcrumbParentTarget() into $lib/collections/paneTarget.ts, alongside the existing resolvePaneTarget/isSamePaneTarget helpers, so the reconstruction logic is unit-testable without mounting ItemDetail. The full-page (!embedded) breadcrumb is unchanged. |
||
|
|
b14953a94e |
feat(web): Phase 1 depth-aware ESC — pop one level at depth>0 (TASK-2163) (#967)
* feat(web): depth-aware ESC pops one drill level in the pane (TASK-2163) At depth>0 in the item pane's mini-browser stack, ESC now runs history.back() to pop exactly one drill level and consumes the key, instead of routing through the list-row helpers (returnFocusToList / resolvePaneReturnTarget) which are meaningless once detached. Only at depth 0 does ESC fall through to the existing two-level return-focus-to-list-then-close behavior — unchanged on both desktop and mobile. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): route depth-aware ESC through the fenced paneHistoryGo Codex review round 1: a bare history.back() never set paneGoInFlight, so repeated ESC presses (or an ESC racing a close/reset click) could queue a second traversal against stale depth and overshoot. Route the pop through the existing paneHistoryGo(-1) fence, matching every other controller history.go call site. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): ignore auto-repeat ESC keydowns in the depth-aware pop Codex review round 2: a held ESC key fires many auto-repeat keydowns, and each one settles the paneHistoryGo in-flight fence before the next arrives, so one held press could unwind several drill levels (even closing the pane). Gate the pop on `!e.repeat` so a held key still consumes ESC but only ever pops the level from the initial physical press. Adds a Playwright test that dispatches synthetic repeat:true keydowns and asserts no further level pops. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): hoist the ESC repeat-guard above the depth-aware branch Codex review round 3: gating !e.repeat only inside the depth>0 branch left a leak at the depth 1->0 boundary — the physical press pops to depth 0, then subsequent repeat:true events (now depth 0, no .item-pane focus) fell through to runTopEscape() and closed the pane within the same held key. Move the repeat check to the top of the ESC chain so every repeat is a pure no-op regardless of what the initial press already changed. Adds a boundary-crossing Playwright test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): detect an open dialog/BottomSheet by existence, not focus Codex review (2nd pass): the ESC handler's dialog guard checked whether the EVENT TARGET was inside a dialog/[role=dialog], but BottomSheet/DockedSheet don't move focus into themselves on open — so Quick Actions / Move To on mobile left document.activeElement on the trigger button back inside .item-pane. ESC then fell through this guard, popped/closed the pane underneath, AND the sheet's own independent window listener also closed it — two layers from one press. Switch to an existence check (`dialog[open], [role="dialog"]`) so it detects an open sheet regardless of focus. `dialog[open]` (not bare `dialog`) because Modal.svelte's native <dialog> is always mounted and toggled via showModal()/close(), so a bare existence check would false-positive on any page with an idle Modal instance. Adds a mobile Playwright regression test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
af7cc1135c |
feat(web): editor content-link pane interception via EditorLinkPopover (TASK-2160) (#968)
* feat(web): editor content-link pane interception via EditorLinkPopover (TASK-2160) Injects the pane-navigate callback at EditorLinkPopover.handleHrefClick, the single goto chokepoint for editor content-body/wiki-links (all other editor anchors are inert data-href + globally preventDefault-ed). A content-link to a same-workspace item now drills the split pane in place when onOpenTarget is wired; modifier/middle-click, external URLs, cross-workspace resolver links (/-/r/), links to other workspaces, and non-item app routes keep navigating. - editorHrefClick.ts: pure planHrefClick() decision extracted for unit testing (mirrors itemCardClick.ts's shouldOpenInPane pattern). - paneTarget.ts: isSameWorkspaceItemHref() validates the href positionally — workspace slug matches current, collection segment is a known collection whose prefix matches the ref's prefix (case-insensitive), and a positive ref number — so a cross-workspace plain path, a non-item route, a self- inconsistent collection/ref path, or a TASK-0 ref can't be misrouted into the current pane's ?item=. - ItemDetail.svelte: threads fireOpenTarget + wsSlug + a collection slug→prefix map into EditorLinkPopover, and only when a real onOpenTarget is wired (full-page / unembedded views keep plain navigation). PLAN-2154 Architecture B.3. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): harden editor content-link pane gate (Codex round 3) - alt-click now passes through to native download (matches shouldOpenInPane and every other pane interceptor). - isSameWorkspaceItemHref rejects malformed double-slash / trailing-slash paths (strict segment parse, no filter(Boolean)) and accepts digit-bearing collection prefixes (e.g. R2-1) by comparing the ref against the KNOWN collection prefix instead of a letters-only REF_SHAPE. - collectionStore stamps `collections` with its source workspace (collectionsWorkspace / collectionsAreFreshFor), mirroring itemsWorkspace; ItemDetail's collection prefix map declines (empty → safe goto fallback) while a workspace switch's loadCollections() is in flight, so a link is never validated against a stale previous-workspace prefix set. TASK-2160 / PLAN-2154 Architecture B.3. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): canonicalize link host + fence collection load order (Codex round 4) - isSameWorkspaceItemHref parses the href with the URL API and requires the resolved host to equal the sentinel base host, so a backslash-authority href ("/\evil.example/...", which browsers resolve cross-origin) or a protocol-relative "//host/..." is treated as external and navigates instead of being misclassified as a local item and pane-drilled. - collectionStore.loadCollections gains a monotonic load-generation guard so a late-resolving previous-workspace response can't clobber the newer workspace's collections / collectionsWorkspace stamp (which would otherwise strand collectionsAreFreshFor at false and suppress valid pane drills). TASK-2160 / PLAN-2154 Architecture B.3. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): require canonical href form in the pane gate (Codex round 5) The round-4 URL-parse fix validated the CANONICALIZED pathname, but the RAW href is what the host later resolves (resolvePaneTarget splits on "/" only), so a backslash-in-segment href like /alice/myws/tasks\TASK-5 normalized to a valid item during validation yet would drill a bogus `tasks\TASK-5` item. Require the raw path to already equal its canonical url.pathname; any non-canonical href (backslash, dot-segments, encoded chars) navigates normally instead. Every item-URL builder emits clean ASCII paths, so this rejects nothing legitimate. TASK-2160 / PLAN-2154 Architecture B.3. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |