* 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).
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