diff --git a/apps/packages/ui/src/context-menu.tsx b/apps/packages/ui/src/context-menu.tsx index 31ceb27435..d140214174 100644 --- a/apps/packages/ui/src/context-menu.tsx +++ b/apps/packages/ui/src/context-menu.tsx @@ -52,6 +52,7 @@ function ContextMenuContent({ .mobile-menu-root)::before { + @apply bg-black/80 supports-backdrop-filter:backdrop-blur-xs; + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + opacity: 0; + transition: opacity 100ms; + } + + [data-radix-popper-content-wrapper]:has(> .mobile-menu-root[data-state="open"])::before { + opacity: 1; } :is( diff --git a/apps/web/e2e/helpers/menu-backdrop.ts b/apps/web/e2e/helpers/menu-backdrop.ts new file mode 100644 index 0000000000..be9694c46c --- /dev/null +++ b/apps/web/e2e/helpers/menu-backdrop.ts @@ -0,0 +1,90 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { waitForFiniteAnimations } from "./animations"; + +export async function readMenuBackdrop(menu: Locator) { + return menu.evaluate((element) => { + const positioner = element.parentElement; + if (!positioner?.hasAttribute("data-radix-popper-content-wrapper")) { + throw new Error("Menu has no positioning wrapper"); + } + const backdrop = getComputedStyle(positioner, "::before"); + return { + content: backdrop.content, + opacity: Number.parseFloat(backdrop.opacity), + background: backdrop.backgroundColor, + blur: backdrop.backdropFilter || backdrop.getPropertyValue("-webkit-backdrop-filter"), + position: backdrop.position, + inset: [backdrop.top, backdrop.right, backdrop.bottom, backdrop.left], + width: Number.parseFloat(backdrop.width), + height: Number.parseFloat(backdrop.height), + zIndex: Number.parseInt(backdrop.zIndex, 10), + pointerEvents: backdrop.pointerEvents, + foregroundFilter: getComputedStyle(element).filter, + wrapperTransform: getComputedStyle(positioner).transform, + wrapperZIndex: Number.parseInt(getComputedStyle(positioner).zIndex, 10), + viewport: { width: window.innerWidth, height: window.innerHeight }, + supportsBlur: + CSS.supports("backdrop-filter", "blur(1px)") || + CSS.supports("-webkit-backdrop-filter", "blur(1px)"), + }; + }); +} + +export async function expectMenuBackdropCount(page: Page, count: number) { + await expect + .poll(() => + page.locator("[data-radix-popper-content-wrapper]").evaluateAll( + (positioners) => + positioners.filter((positioner) => { + if (!positioner.querySelector(':scope > [role="menu"]')) return false; + const style = getComputedStyle(positioner, "::before"); + return !["none", "normal"].includes(style.content) && style.display !== "none"; + }).length, + ), + ) + .toBe(count); +} + +export async function expectMobileMenuBackdrop(menu: Locator) { + await expect(menu).toBeVisible(); + await waitForFiniteAnimations(menu); + await expect + .poll(async () => ["none", "normal"].includes((await readMenuBackdrop(menu)).content)) + .toBe(false); + const backdrop = await readMenuBackdrop(menu); + expect(backdrop.opacity).toBe(1); + expect(backdrop.background).not.toBe("rgba(0, 0, 0, 0)"); + expect(backdrop.position).toBe("fixed"); + expect(backdrop.inset).toEqual(["0px", "0px", "0px", "0px"]); + expect(backdrop.width).toBeCloseTo(backdrop.viewport.width, 0); + expect(backdrop.height).toBeCloseTo(backdrop.viewport.height, 0); + expect(backdrop.zIndex).toBeLessThan(0); + expect(backdrop.pointerEvents).toBe("none"); + expect(backdrop.foregroundFilter).toMatch(/^(none|blur\(0px\))$/); + expect(backdrop.wrapperTransform).toBe("none"); + expect(backdrop.wrapperZIndex).toBeGreaterThan(0); + if (backdrop.supportsBlur) { + expect( + Number.parseFloat(backdrop.blur.match(/blur\(([\d.]+)px\)/)?.[1] ?? "0"), + ).toBeGreaterThan(0); + } + return backdrop; +} + +export async function expectReachableMenuItem(item: Locator) { + await item.scrollIntoViewIfNeeded(); + await expect(item).toBeVisible(); + const hit = await item.evaluate(async (element) => { + const menu = element.closest('[role="menu"]'); + await Promise.all( + (menu?.getAnimations({ subtree: true }) ?? []) + .filter((animation) => Number.isFinite(animation.effect?.getComputedTiming().iterations)) + .map((animation) => animation.finished.catch(() => undefined)), + ); + const box = element.getBoundingClientRect(); + const target = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2); + return { reachable: !!target && element.contains(target), height: box.height }; + }); + expect(hit.reachable).toBe(true); + expect(Math.round(hit.height)).toBeGreaterThanOrEqual(44); +} diff --git a/apps/web/e2e/tests/layout/menu-backdrops.spec.ts b/apps/web/e2e/tests/layout/menu-backdrops.spec.ts new file mode 100644 index 0000000000..b41cafe0ab --- /dev/null +++ b/apps/web/e2e/tests/layout/menu-backdrops.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "../../fixtures/test-base"; +import { expectMenuBackdropCount, expectMobileMenuBackdrop } from "../../helpers/menu-backdrop"; +import { KanbanPage } from "../../pages/kanban-page"; + +test.describe("Menu backdrop viewport boundaries", () => { + // @covers AC-UI-MOBILE-TASK-NAVIGATION-001.11, AC-UI-MOBILE-TASK-NAVIGATION-001.12 + test("menu backdrop follows the 640px boundary while open", async ({ + testPage, + apiClient, + seedData, + }) => { + await testPage.setViewportSize({ width: 639, height: 900 }); + const task = await apiClient.createTask(seedData.workspaceId, "Backdrop boundary task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.taskCard(task.id).getByRole("button", { name: "More options" }).click(); + const menu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]'); + await expectMobileMenuBackdrop(menu); + await expectMenuBackdropCount(testPage, 1); + + await testPage.setViewportSize({ width: 640, height: 900 }); + await expect(menu).toBeVisible(); + await expectMenuBackdropCount(testPage, 0); + const desktopBox = await menu.boundingBox(); + expect(desktopBox!.width).toBeLessThan(400); + await testPage.setViewportSize({ width: 639, height: 900 }); + await expectMobileMenuBackdrop(menu); + await expectMenuBackdropCount(testPage, 1); + await testPage.keyboard.press("Escape"); + await expectMenuBackdropCount(testPage, 0); + }); + + // @covers AC-UI-MOBILE-TASK-NAVIGATION-001.12 + test("desktop menus keep anchored actions without a backdrop", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTask(seedData.workspaceId, "Desktop backdrop task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const card = kanban.taskCard(task.id); + await card.hover(); + const trigger = card.getByRole("button", { name: "More options" }); + await trigger.click(); + const dropdown = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]'); + await expect(dropdown).toBeVisible(); + await expectMenuBackdropCount(testPage, 0); + expect((await dropdown.boundingBox())!.width).toBeLessThan(400); + await testPage.keyboard.press("Escape"); + await expect(trigger).toBeFocused(); + + await card.click({ button: "right" }); + const contextMenu = testPage.locator('[data-slot="context-menu-content"][data-state="open"]'); + await expect(contextMenu).toBeVisible(); + await expectMenuBackdropCount(testPage, 0); + expect((await contextMenu.boundingBox())!.width).toBeLessThan(400); + await contextMenu.getByTestId("task-context-priority").hover(); + await testPage.getByTestId("task-context-priority-low").click(); + await expect.poll(async () => (await apiClient.getTask(task.id)).priority).toBe("low"); + await expect(contextMenu).toBeHidden(); + await expectMenuBackdropCount(testPage, 0); + }); +}); diff --git a/apps/web/e2e/tests/layout/mobile-menu-backdrops.spec.ts b/apps/web/e2e/tests/layout/mobile-menu-backdrops.spec.ts new file mode 100644 index 0000000000..58e67146c0 --- /dev/null +++ b/apps/web/e2e/tests/layout/mobile-menu-backdrops.spec.ts @@ -0,0 +1,192 @@ +import { test, expect } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { + expectMenuBackdropCount, + expectMobileMenuBackdrop, + expectReachableMenuItem, +} from "../../helpers/menu-backdrop"; +import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; +import { SessionPage } from "../../pages/session-page"; + +test.describe("Mobile menu backdrops", () => { + // @covers AC-UI-MOBILE-TASK-NAVIGATION-001.11 + test("the backdrop fades with the closing menu sheet", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTask(seedData.workspaceId, "Closing backdrop task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + await mobile.taskCard(task.id).getByRole("button", { name: "More options" }).tap(); + const menu = testPage.locator('[data-slot="dropdown-menu-content"]'); + await expectMobileMenuBackdrop(menu); + + const closeFrame = await menu.evaluateHandle((element) => { + const frame = { content: "", opacity: "", transition: "", duration: "" }; + const observer = new MutationObserver(() => { + if (element.getAttribute("data-state") !== "closed") return; + const backdrop = getComputedStyle(element.parentElement!, "::before"); + frame.content = backdrop.content; + frame.opacity = backdrop.opacity; + frame.transition = backdrop.transitionProperty; + frame.duration = backdrop.transitionDuration; + observer.disconnect(); + }); + observer.observe(element, { attributes: true, attributeFilter: ["data-state"] }); + return frame; + }); + await testPage.keyboard.press("Escape"); + const closing = await closeFrame.jsonValue(); + expect(["none", "normal"]).not.toContain(closing.content); + expect(Number.parseFloat(closing.opacity)).toBeGreaterThan(0); + expect(closing.transition).toBe("opacity"); + expect(closing.duration).toBe("0.1s"); + await closeFrame.dispose(); + await expect(menu).toHaveCount(0); + await expectMenuBackdropCount(testPage, 0); + }); + + // @covers AC-UI-MOBILE-TASK-NAVIGATION-001.3, AC-UI-MOBILE-TASK-NAVIGATION-001.9, AC-UI-MOBILE-TASK-NAVIGATION-001.11 + test("Kanban task options blur the background and dismiss cleanly", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + const task = await apiClient.createTask(seedData.workspaceId, "Mobile backdrop task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + const trigger = mobile.taskCard(task.id).getByRole("button", { name: "More options" }); + const menu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]'); + + for (const colorScheme of ["light", "dark"] as const) { + await testPage.emulateMedia({ colorScheme }); + await expect(testPage.locator("html")).toHaveClass(new RegExp(`\\b${colorScheme}\\b`)); + await mobile.mobileMenuButton.tap(); + const drawer = testPage.getByRole("dialog", { name: "Menu", exact: true }); + await expect(drawer).toBeVisible(); + const drawerBackdrop = await testPage + .locator('[data-slot="drawer-overlay"]:visible') + .evaluate((element) => { + const style = getComputedStyle(element); + return { background: style.backgroundColor, blur: style.backdropFilter }; + }); + await testPage.keyboard.press("Escape"); + await expect(drawer).toBeHidden(); + + await trigger.tap(); + const backdrop = await expectMobileMenuBackdrop(menu); + expect(backdrop.background).toBe(drawerBackdrop.background); + expect(backdrop.blur).toBe(drawerBackdrop.blur); + await expectMenuBackdropCount(testPage, 1); + await expectReachableMenuItem(menu.getByRole("menuitem", { name: "Edit", exact: true })); + await assertNoDocumentHorizontalOverflow(testPage); + await prCapture.screenshot(`kanban-menu-${colorScheme}`, { + caption: `Mobile task options with a blurred background in ${colorScheme} mode`, + }); + + if (colorScheme === "light") await testPage.touchscreen.tap(20, 20); + else await testPage.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + await expectMenuBackdropCount(testPage, 0); + await expect(trigger).toBeFocused(); + await expect(testPage).toHaveURL(/\/$/); + } + + await trigger.tap(); + await expectMenuBackdropCount(testPage, 1); + await apiClient.deleteTask(task.id); + await expect(mobile.taskCard(task.id)).toBeHidden(); + await expectMenuBackdropCount(testPage, 0); + await mobile.mobileMenuButton.tap(); + await expect(mobile.menuCard).toBeVisible(); + }); + + // @covers AC-UI-MOBILE-TASK-NAVIGATION-001.3, AC-UI-MOBILE-TASK-NAVIGATION-001.10, AC-UI-MOBILE-TASK-NAVIGATION-001.11 + test("task submenus share one backdrop and preserve the task drawer", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + const task = await apiClient.createTask(seedData.workspaceId, "Nested backdrop task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await testPage.getByTestId("mobile-session-menu").tap(); + const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); + const row = drawer.getByTestId("sidebar-task-item").filter({ hasText: task.title }); + const actions = row.getByRole("button", { name: "Task actions" }); + await actions.tap(); + const rootMenu = testPage.locator('[data-slot="context-menu-content"][data-state="open"]'); + await expectMobileMenuBackdrop(rootMenu); + await expectMenuBackdropCount(testPage, 1); + + const priority = rootMenu.getByTestId("task-context-priority"); + await priority.tap(); + const low = testPage.getByTestId("task-context-priority-low"); + await expectReachableMenuItem(low); + await expectMenuBackdropCount(testPage, 1); + await prCapture.screenshot("nested-task-menu", { + caption: "A task priority submenu retains one menu backdrop above the task drawer", + }); + await low.press("ArrowLeft"); + await expect(low).toBeHidden(); + await expect(rootMenu).toBeVisible(); + await expectMenuBackdropCount(testPage, 1); + await priority.tap(); + await low.tap(); + await expect.poll(async () => (await apiClient.getTask(task.id)).priority).toBe("low"); + await expectMenuBackdropCount(testPage, 0); + await expect(drawer).toBeVisible(); + await expect(testPage.locator('[data-slot="drawer-overlay"]:visible')).toHaveCount(1); + await assertNoDocumentHorizontalOverflow(testPage); + await actions.tap(); + await expectMobileMenuBackdrop(rootMenu); + await priority.tap(); + await expect(low).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expectMenuBackdropCount(testPage, 0); + await expect(drawer).toBeVisible(); + }); + + // @covers AC-UI-MOBILE-TASK-NAVIGATION-001.9, AC-UI-MOBILE-TASK-NAVIGATION-001.11, AC-UI-MOBILE-TASK-NAVIGATION-001.12 + test("workspace menu preserves non-modal drawer interaction", async ({ testPage, apiClient }) => { + const otherWorkspace = await apiClient.createWorkspace("Backdrop workspace"); + await apiClient.createWorkflow(otherWorkspace.id, "Backdrop workflow", "simple"); + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + await mobile.mobileMenuButton.tap(); + const drawer = testPage.getByRole("dialog", { name: "Menu", exact: true }); + const trigger = testPage.getByTestId("mobile-workspace-trigger"); + await trigger.tap(); + const menu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]'); + await expectMobileMenuBackdrop(menu); + await expectMenuBackdropCount(testPage, 1); + await drawer.getByRole("heading", { name: "Menu", exact: true }).tap(); + await expect(menu).toBeHidden(); + await expectMenuBackdropCount(testPage, 0); + await expect(drawer).toBeVisible(); + await expect(testPage.locator('[data-slot="dropdown-menu-content"]')).toHaveCount(0); + await trigger.tap(); + await expectMobileMenuBackdrop(menu); + const choice = testPage.getByTestId(`mobile-workspace-item-${otherWorkspace.id}`); + await expectReachableMenuItem(choice); + await choice.tap(); + await expect(drawer).toBeHidden(); + await expectMenuBackdropCount(testPage, 0); + await mobile.mobileMenuButton.tap(); + await expect(trigger).toContainText(otherWorkspace.name); + await assertNoDocumentHorizontalOverflow(testPage); + }); +}); diff --git a/docs/plans/mobile-menu-backdrops/plan.md b/docs/plans/mobile-menu-backdrops/plan.md new file mode 100644 index 0000000000..895350f28b --- /dev/null +++ b/docs/plans/mobile-menu-backdrops/plan.md @@ -0,0 +1,178 @@ +--- +created: 2026-09-10 +status: complete +requirements: + - REQ-UI-MOBILE-TASK-NAVIGATION-001 +system_design: + - ../../specs/ui/system-design/mobile-menu-backdrops.md +legacy_specs: [] +--- + +# Implementation Plan: Mobile Menu Backdrops + +## Overview + +Give every shared dropdown/context bottom sheet the background dimming and +blur already used by mobile drawers. One work order adds focused browser +regressions, applies shared root markers and CSS, and verifies nested menus +and desktop compatibility. + +The requirements are amended in +[Mobile Task Navigation](../../specs/ui/requirements/mobile-task-navigation.md), +criteria 001.9 through 001.12. The owning contract remains UI presentation; +adjacent task and workspace specifications own state and actions. Existing +criterion 001.3 covers menu geometry but did not require background blur. + +## Confirmed cause and audit + +Source trace at `796bf5853`: + +1. `KanbanCardMenu` in `apps/web/components/kanban-card-content.tsx` renders + `DropdownMenuContent` for the visible **More options** action. +2. `apps/web/app/globals.css` converts dropdown/context root and submenu + positioners into bottom sheets below 640px. It sets geometry, scrolling, + and touch targets but creates no backdrop. +3. Both shared menu primitives render content without an overlay. In contrast, + `DrawerContent` and `SheetContent` include overlays with supported blur. +4. The existing test `renders kanban card dropdown as a mobile bottom sheet` + checks bounds and row height but never checks backdrop rendering. + +The source audit identifies these affected entry points. Rows describe paths +through the shared primitives, not a claim that every path was browser-tested. + +| Surface | Source | Backdrop finding | +| --- | --- | --- | +| Kanban card More options | `components/kanban-card-content.tsx` | Root dropdown has no backdrop | +| Task-list and phone task-switcher Task actions | `components/task/task-item-menu-button.tsx`, `components/task/task-switcher-context-menu.tsx` | Explicit button opens the shared context menu | +| Task options such as Move to, Link, and Priority | `components/kanban-card-menu-items.tsx`, `components/task/task-switcher-context-menu.tsx` | Nested choices need to retain the root backdrop | +| Phone session options and handoff choices | `components/task/mobile/mobile-sessions-section.tsx` | Dropdown inside a phone picker | +| Home workspace selector | `components/kanban/mobile-menu-sheet.tsx`, `components/app-sidebar/app-sidebar-workspace-picker.tsx` | Non-modal dropdown inside an existing Drawer | +| File-tree touch actions | `components/task/file-browser-parts.tsx` | Explicit touch dropdown uses the same root | +| Topbar breadcrumb and action overflow | `components/page-topbar.tsx` | Non-task dropdowns share the missing treatment | +| Quick Chat add-tab menu | `components/quick-chat/quick-tab-add-menu.tsx` | Menu inherits the same phone presentation | +| Integration start-task choices | `components/integrations/integration-start-task-menu.tsx` | Shared dropdown inherits the fix wherever rendered below 640px | +| Home navigation, board navigator, task/session picker, status drawers | `components/kanban/mobile-menu-sheet.tsx`, `components/task/mobile/mobile-picker-sheet.tsx`, `components/app-status-bar/app-status-drawer.tsx` | Existing Drawer overlay already specifies blur | +| Wider Sheet surfaces and AlertDialog | `apps/packages/ui/src/sheet.tsx`, `apps/packages/ui/src/alert-dialog.tsx` | Existing overlay already specifies blur | +| Ordinary Dialog and mobile Quick Chat surface | `apps/packages/ui/src/dialog.tsx`, `components/quick-chat/quick-chat-modal.tsx` | Separate contracts; no generic blur change | + +Component paths without a prefix in this table are relative to `apps/web/`. +The mobile Kanban card disables its separate long-press context wrapper; +the reported trigger is the dropdown, not `KanbanCardContextMenu`. + +## Scope + +### In scope + +- Shared root-menu marker and a viewport-covering backdrop below 640px. +- Root/submenu lifecycle, enclosing Drawer behavior, and non-modal compatibility. +- Browser regression coverage and rendered evidence for the existing action flows. + +### Out of scope + +- Changes to task actions, permissions, state, routes, or data persistence. +- Menu redesign, new drawers, changing the 640px menu breakpoint, or widening + blur to dialogs, popovers, Select, and editor toolbars. +- General UI cleanup, new copy, translations, package changes, or feature flags. + +## Technical approach + +Implement the [design](../../specs/ui/system-design/mobile-menu-backdrops.md) +with a shared `mobile-menu-root` class on `DropdownMenuContent` and +`ContextMenuContent`, plus a scoped positioner `::before` rule inside the +existing phone media query. Reuse the Drawer backdrop utilities. Keep Radix +state and interaction ownership intact; submenu content receives no marker. +Reset the positioner's inline `will-change: transform` hint alongside its +transform so the fixed backdrop's containing block is the viewport. + +Document the resulting shared rule in the responsive-surface section of +`apps/web/AGENTS.md`. Public docs need no update: this package records a visual +repair and changes no workflow, label, or public instruction. + +## Tests + +This is CSS and component markup, with no new state or business logic. +Browser regressions are the primary evidence. Do not add source-text assertions +or a parallel state machine solely to test backdrop presence. + +## E2E tests + +| Test file and scenario | Criteria | +| --- | --- | +| `e2e/tests/layout/mobile-menu-backdrops.spec.ts`: **the backdrop fades with the closing menu sheet** | 001.11 | +| New `e2e/tests/layout/mobile-menu-backdrops.spec.ts`: **Kanban task options blur the background and dismiss cleanly** | 001.3, 001.9, 001.11 | +| Same file: **task submenus share one backdrop and preserve the task drawer** | 001.3, 001.10, 001.11 | +| Same file: **workspace menu preserves non-modal drawer interaction** | 001.9, 001.11, 001.12 | +| New `e2e/tests/layout/menu-backdrops.spec.ts`: **menu backdrop follows the 640px boundary while open** | 001.11, 001.12 | +| Same file: **desktop menus keep anchored actions without a backdrop** | 001.12 | + +Use `mobile-chrome` for the mobile file and `chromium` for boundary/desktop +coverage. Reuse `MobileKanbanPage`, `SessionPage`, and fixture seed APIs. +Place genuinely shared computed-style assertions in +`e2e/helpers/menu-backdrop.ts`. Existing mobile Kanban and sidebar tests provide +nearby interaction patterns and selected compatibility checks. + +## Work orders + +- [x] [Task 01: Add shared mobile menu backdrops](task-01-add-menu-backdrops.md) (done) + +Run the work order sequentially after the explicit implementation request. +There is no delegation or publication step in this package. + +## Initial implementation verification + +- Implementation completed after the user's explicit implementation request. + The change is two shared root markers and phone-only CSS; no action handler, + dependency, additional portal, or menu state changed. +- Red: the Kanban browser regression failed before production changes because + the root positioner's `::before` content was `none` rather than generated. +- Browser inspection also identified Radix's `will-change: transform` hint as + a containing-block constraint. Clearing it changed backdrop coverage from + the menu's 377 x 289px bounds to the full 393 x 851px viewport. +- Green: all five new browser scenarios and all four selected compatibility + scenarios passed. Typecheck, targeted ESLint, and Prettier checks passed. +- One intermediate Kanban run reported a missing backdrop. A diagnostic run, + five sequential traced repetitions, and the final full mobile file passed; + the intermittent result was not reproduced again. See the work order for + execution details and the local desktop-discovery workaround. +- Visual evidence: inspected light/dark Kanban menus and a nested task menu + in Chromium. The background is blurred and foreground text stays sharp. + WebKit and physical-device rendering were not tested. +- The runtime and browser were isolated from the user's application and data. + No public documentation or localization changes were required. +- Specification validation: all files passed; all 30 specification-linter tests + passed. `git diff --check` passed. + +## PR review remediation + +- Reproduced the premature backdrop removal with an exit-state browser + regression, then added a 100ms opacity transition matching the menu sheet. + The requirement, design, and shared-component guidance now record that + lifecycle explicitly. +- Strengthened browser assertions for visible opacity and the Radix wrapper's + positive stacking context. Generated-content checks no longer depend on + one browser's empty-string serialization. Aligned the index link with the + design document's title. +- Actual WebKit verification passed the exit, nested-task, and non-modal + workspace scenarios. The Kanban scenario passed its backdrop assertions but + exposed an outside-tap limitation: tapping the HTML background emitted no + click, which Radix's touch dismissal awaits. The same failure reproduced + with the PR's backdrop marker removed and original positioning hint restored. + This is not a passing full WebKit suite or physical-iOS verification; it does + not change the existing Chromium-only CI browser contract. +- See the work order for focused commands and validation evidence. Remote CI + and review evidence belongs to the current PR head, not these historical + local results. + +## Risks + +- A backdrop inside scrolling menu content would be clipped; keep it on the + positioning wrapper and verify long menus. +- A transformed or incorrectly stacked ancestor can change viewport coverage; + inspect computed styles and screenshot the actual portaled hierarchy. +- Adding markers to submenus would compound dimming and blur. +- A pointer-active overlay could break the non-modal Home workspace picker or + cause different outside-dismiss behavior. +- Drawer/menu nesting must leave the parent interactive after dismissal and + must not let a tap on an action begin a task drag. +- The configured phone browser is Chromium; actual WebKit rendering remains a + compatibility consideration beyond that project's evidence. diff --git a/docs/plans/mobile-menu-backdrops/task-01-add-menu-backdrops.md b/docs/plans/mobile-menu-backdrops/task-01-add-menu-backdrops.md new file mode 100644 index 0000000000..7b082b9dd7 --- /dev/null +++ b/docs/plans/mobile-menu-backdrops/task-01-add-menu-backdrops.md @@ -0,0 +1,267 @@ +--- +id: "01-add-menu-backdrops" +title: "Add shared mobile menu backdrops" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-MOBILE-TASK-NAVIGATION-001 +acceptance_criteria: + - AC-UI-MOBILE-TASK-NAVIGATION-001.3 + - AC-UI-MOBILE-TASK-NAVIGATION-001.9 + - AC-UI-MOBILE-TASK-NAVIGATION-001.10 + - AC-UI-MOBILE-TASK-NAVIGATION-001.11 + - AC-UI-MOBILE-TASK-NAVIGATION-001.12 +system_design: + - ../../specs/ui/system-design/mobile-menu-backdrops.md +--- + +# Task 01: Add Shared Mobile Menu Backdrops + +## Summary + +Extend shared dropdown/context bottom-sheet styling with a backdrop matching +the existing Drawer treatment. Prove the rendered result, nested interactions, +cleanup, and the existing desktop boundary through focused browser tests. + +## In scope + +- Add the `mobile-menu-root` marker to both shared root content components. +- Add a phone-only decorative backdrop to the immediate positioning wrapper + while the marked root content is open, following the system design. +- Implement the five named scenarios in the plan, with shared assertions in + `e2e/helpers/menu-backdrop.ts` only where used by both files. +- Update scoped frontend guidance for the shared backdrop rule. + +## Out of scope + +- Consumer-specific backdrop patches, new menu state, extra portals, or new + event handlers and listeners. +- Changes to Drawer, Sheet, Dialog, Select, Popover, menu breakpoints, or action + handlers; public documentation, translations, or dependencies. + +## Acceptance + +1. The current Kanban trigger produces the expected failing browser regression + before the fix. After the fix, both root primitive families show a viewport + backdrop with supported blur, sharp foreground content, internal scrolling, + safe-area spacing, and reachable 44px rows. +2. Nested context choices create no additional menu backdrop. Selecting a + priority or move action succeeds; dismissing a child menu leaves the parent + Drawer usable. Outside tap, Escape, menu unmount, and non-modal workspace + selection remove only the appropriate layer without leaking a click into + task navigation or drag behavior. +3. At 639px the open menu has a backdrop; at 640px and normal desktop widths it + has none and remains anchored. Resizing while open follows this boundary. + Focus returns as before and closing restores normal page interaction. + +## Verification + +Bootstrap this fresh worktree from the repository root: + +```bash +cd apps && pnpm install --frozen-lockfile +``` + +Write browser regressions first and run the mobile file before changing +production code. Record the missing-backdrop failure, not a fixture or build +failure. Use real `.tap()` input for mobile actions. For geometry, await only +finite animations and tolerate cancellation rather than sleeping. + +From `apps/web`, run these commands sequentially. The managed runner builds +the current application and owns its isolated backend and cleanup: + +```bash +pnpm e2e:run --project mobile-chrome tests/layout/mobile-menu-backdrops.spec.ts -- --retries=0 +pnpm e2e:run --project chromium tests/layout/menu-backdrops.spec.ts -- --retries=0 +pnpm e2e:run --project mobile-chrome tests/kanban/mobile-kanban.spec.ts -- --grep 'renders kanban card dropdown as a mobile bottom sheet|switches workspaces from the mobile menu' --retries=0 +pnpm e2e:run --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts -- --grep 'opens a viewport-bound action sheet without covering diff stats|moves a task to another step from the mobile task drawer' --retries=0 +pnpm run typecheck +pnpm exec eslint --max-warnings 0 e2e/helpers/menu-backdrop.ts e2e/tests/layout/mobile-menu-backdrops.spec.ts e2e/tests/layout/menu-backdrops.spec.ts +``` + +Do not run these suites concurrently. Confirm test discovery in each named +project. Do not create a permanent per-test phone-device override; use the +configured Pixel 5 project. The desktop boundary scenario can resize its +viewport to 639px and 640px because the existing menu rule is width-based. + +Check computed pseudo-element content, background opacity, supported blur, +fixed viewport coverage, pointer transparency, and relative stacking. Compare +the effective dimming and blur against an open Drawer. Verify a real menu row +is returned by `elementFromPoint` at its center before completing its action. +Assert no document horizontal overflow and one generated backdrop per menu +hierarchy. Include a close/unmount case and light/dark theme coverage. + +Capture a rendered phone screenshot for the Kanban menu and a nested context +menu. Inspect that background content is blurred while text remains sharp. +Use the isolated application or test fixture, never the developer's live +instance. Record the configured browser and any limits of visual evidence. + +From the repository root, verify the edited documents and whitespace: + +```bash +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +- `apps/packages/ui/src/dropdown-menu.tsx` +- `apps/packages/ui/src/context-menu.tsx` +- `apps/web/app/globals.css` +- `apps/web/AGENTS.md` +- `apps/web/e2e/helpers/menu-backdrop.ts` (new) +- `apps/web/e2e/tests/layout/mobile-menu-backdrops.spec.ts` (new) +- `apps/web/e2e/tests/layout/menu-backdrops.spec.ts` (new) + +## Dependencies + +None. Install workspace dependencies before package commands. Follow `/tdd`, +`/e2e`, and `/mobile-parity` during implementation. + +## Risks + +- Pseudo-element stacking depends on the actual root positioning wrapper; + source-text assertions cannot establish visual correctness. +- Parent Drawer overlays and non-modal workspace menus must keep their current + lifetime and event handling. +- Browser builds lacking filter support must retain dimming. Chromium alone + does not prove WebKit rendering. + +## Parallelism + +`sequential` + +## Inputs + +- [Mobile Task Navigation requirements](../../specs/ui/requirements/mobile-task-navigation.md) +- [Mobile Menu Backdrops design](../../specs/ui/system-design/mobile-menu-backdrops.md) +- Existing backdrop: `apps/packages/ui/src/drawer.tsx`. +- Existing bottom-sheet CSS: `apps/web/app/globals.css`. +- Existing browser patterns: `e2e/tests/kanban/mobile-kanban.spec.ts`, + `e2e/tests/task/mobile-sidebar-task-actions.spec.ts`, and + `e2e/tests/task/mobile-external-link-menu.spec.ts`, relative to `apps/web/`. + +## Results + +Completed on 2026-09-10 after the explicit implementation request. + +### Implementation and regression evidence + +- Added `mobile-menu-root` only to root dropdown/context content. Added one + pointer-transparent, viewport-sized backdrop on its open positioning wrapper, + below 640px, using the existing Drawer dimming and supported blur utilities. +- Reset Radix's inline `will-change: transform` hint in the existing phone + positioner rule. Browser inspection confirmed the hint otherwise constrains + the fixed backdrop to menu bounds despite `transform: none`. +- The first Kanban regression failed before production changes: expected + generated pseudo-element content, received `none`. The final mobile run + passed all three scenarios, including light/dark comparison against Drawer, + outside tap, Escape, unmount cleanup, nested priority selection, and workspace + selection inside a non-modal parent Drawer. +- Browser assertions accept the existing animation's `blur(0px)` as sharp + content. Submenu tests preserve Radix behavior: ArrowLeft closes the child; + Escape closes the menu hierarchy and leaves the enclosing Drawer open. + Workspace reopening waits for the previous root's exit presence to detach. +- One intermediate Kanban run reported absent generated content. The immediate + diagnostic run, five sequential traced repetitions, and final full mobile + run passed without another production change. The intermittent result was + not reproduced in those checks. + +### Completed checks + +| Check | Result | +| --- | --- | +| New mobile backdrop file, configured Pixel 5 / `mobile-chrome` | 3 passed | +| New boundary/desktop file, configured Desktop Chrome / `chromium` | 2 passed | +| Selected existing mobile Kanban compatibility scenarios | 2 passed | +| Selected existing mobile sidebar compatibility scenarios | 2 passed | +| Additional traced Kanban repetitions, one worker, no retries | 5 passed | +| Web typecheck | Passed | +| Targeted ESLint and Prettier | Passed | + +Installed dependencies with `pnpm install --frozen-lockfile` from `apps/`. +Built the backend, mock helpers, and E2E plugin package through the repository +targets, then built current frontend assets with +`pnpm --filter @kandev/web build:e2e`. The focused managed runs used `--host +--no-build` to reuse those verified current artifacts, one worker, and +`--retries=0`. The final mobile command added `CAPTURE_PR_ASSETS=1` and +`--trace=on`; the diagnostic command filtered `Kanban task options` and added +`--repeat-each=5 --trace=on`. + +The normal desktop invocation found no tests because its existing +`/mobile-.*\.spec\.ts/` ignore expression also matches the absolute worktree +parent `some-mobile-menu-bot_6he5618a`. A temporary config imported the normal +config, retained the `chromium` project's browser settings, selected only +`**/menu-backdrops.spec.ts`, and cleared that project's ignore list. The same +managed command with `--config=e2e/menu-backdrops-verification.config.ts` +passed both cases. The temporary config was removed; the shared Playwright +configuration was not changed. + +### Visual evidence and cleanup + +Before publication, refreshed the captures in one disposable mobile capture +scenario, using the same isolated Pixel 5 fixture. The capture passed and its +temporary spec was removed. Inspected these assets in `apps/web/.pr-assets/`: + +- `mobile-menu-pr-capture--kanban-light.png` +- `mobile-menu-pr-capture--kanban-dark.png` +- `mobile-menu-pr-capture--nested-task-menu.png` + +The fresh captures wait for transient update toasts to dismiss normally. +No desktop screenshot is required because the new backdrop is structurally +absent at 640px and above; the desktop and boundary tests cover that behavior. +This evidence is Chromium-only, not a WebKit or physical-device validation. + +Closed the named verification browser and stopped the owned isolated runtime. +The managed browser fixtures handled their own backend and test-data cleanup. +No live user instance or data was used. Commit and publication follow the +user's separate PR request. + +### PR review remediation + +- Added `the backdrop fades with the closing menu sheet`. The regression + observed the actual Radix close-state mutation and failed with backdrop + `content: none` before the fix. After rebuilding, it passed with a generated, + still-visible backdrop transitioning opacity over 100ms and disappearing + when Radix unmounts the sheet. +- Root backdrops now remain generated through exit presence, with closed + content transparent after the transition. Updated criterion 001.11, the + design, and shared menu guidance. Desktop media-query behavior is unchanged. +- Browser helpers also assert visible opacity and a positive wrapper z-index; + the latter verifies Radix's existing stacking context without overriding it. + Generated-content checks accept browser-specific serialization. Corrected + the UI index link to match the design H1. +- Actual WebKit 26.5 (Playwright build 2311), using the iPhone 13 profile, + passed exit motion, nested task submenus, and non-modal workspace selection. + Kanban dimming/blur assertions passed, but its outside-tap dismissal failed. + A disposable baseline test removed `mobile-menu-root` and restored the + original `will-change: transform` hint; the same dismissal failed. Captured + events were `pointerdown`, `touchstart`, `pointerup`, and `touchend` on HTML, + with no `click`. Radix waits for that click to dismiss a touch interaction. + No dismissal workaround, test skip, or expanded CI project was introduced. +- WebKit's missing Ubuntu libraries were downloaded and extracted into a + task-owned temporary directory, not installed on the host. Its wrapper + overwrites `LD_LIBRARY_PATH`, so the temporary verification config launched + the pinned MiniBrowser directly with its bundled paths plus those libraries. + The config retained normal fixtures, one worker, and the iPhone 13 device. + +Commands from `apps/web` (production assets rebuilt first): + +```sh +pnpm e2e:run --host --no-build --project mobile-chrome tests/layout/mobile-menu-backdrops.spec.ts -- --retries=0 --trace=on +pnpm e2e:run --host --no-build --project chromium tests/layout/menu-backdrops.spec.ts -- --config=e2e/menu-backdrops-verification.config.ts --retries=0 +pnpm e2e:run --host --no-build --project mobile-webkit tests/layout/mobile-menu-backdrops.spec.ts -- --config=e2e/menu-backdrops-verification.config.ts --retries=0 --trace=on +``` + +The temporary config and baseline diagnostic are verification-only artifacts, +not changes to the repository's standard browser matrix. WebKit remains a +partial validation, with the touch-dismissal limitation above recorded explicitly. + +Final focused Chromium verification passed all four mobile backdrop cases, +both desktop/breakpoint cases, and all four compatibility cases from the initial +work order, with retries disabled. Typecheck, targeted ESLint/Prettier, +specification checks, all 19 harness-linter tests, all 30 specification-linter +tests, the all-file harness audit, and the targeted harness hook passed. diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index a69a1b829a..16d241f435 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -179,6 +179,7 @@ UI owns responsive behavior; other systems own behavior/state. - [Message Queue Automation Controls (current)](system-design/message-queue-automation-controls.md) - [Native HTML File Preview](system-design/native-html-preview.md) - [Mobile Task Chrome](system-design/mobile-task-chrome.md) +- [Mobile Menu Backdrops System Design](system-design/mobile-menu-backdrops.md) - [Persistent status motion](system-design/persistent-status-motion.md) - [Repository Groups](system-design/sidebar-repository-grouping.md) - [Sidebar Automatic Task Colors](system-design/sidebar-automatic-task-colors.md) diff --git a/docs/specs/ui/requirements/mobile-task-navigation.md b/docs/specs/ui/requirements/mobile-task-navigation.md index bed093f2d9..b74b7a6026 100644 --- a/docs/specs/ui/requirements/mobile-task-navigation.md +++ b/docs/specs/ui/requirements/mobile-task-navigation.md @@ -27,6 +27,16 @@ Mobile users need the same task controls as desktop without relying on long pres - **AC-UI-MOBILE-TASK-NAVIGATION-001.6:** Opening the board navigation control exposes available workflows and the focused workflow's steps in one bottom drawer. Choosing a workflow makes it the active workflow for the board, task creation, and multi-select actions through the existing saved workflow selection; previous/next step buttons and horizontal swipe remain equivalent transient step shortcuts. - **AC-UI-MOBILE-TASK-NAVIGATION-001.7:** The task list is the primary vertical scroller. The document and workflow container do not require horizontal scrolling. - **AC-UI-MOBILE-TASK-NAVIGATION-001.8:** Search and live workflow/task updates choose a deterministic visible fallback if the focused workflow disappears. +- **AC-UI-MOBILE-TASK-NAVIGATION-001.9:** When a dropdown or context action menu opens as a bottom sheet below 640px, its background shall be dimmed and lightly blurred using the same treatment as the existing mobile navigation drawers. The menu text and controls shall remain sharp. Browsers without background-blur support shall retain the dimming. +- **AC-UI-MOBILE-TASK-NAVIGATION-001.10:** When a user enters or leaves a submenu, the menu hierarchy shall retain one backdrop without adding another darkening or blur layer for each submenu. An enclosing drawer shall retain its own existing backdrop and remain usable after the menu closes. +- **AC-UI-MOBILE-TASK-NAVIGATION-001.11:** When the root menu closes, its backdrop shall fade with the sheet's exit motion and leave no visible layer afterward. Unmounting or leaving the bottom-sheet viewport range shall remove the backdrop immediately. Outside-tap dismissal, Escape, focus return, action selection, and any existing non-modal interaction shall retain their current behavior. +- **AC-UI-MOBILE-TASK-NAVIGATION-001.12:** The backdrop treatment shall apply consistently to task and non-task dropdown/context bottom sheets, including workspace selection, session options, file actions, and topbar overflow. At widths of 640px and above, those menus shall retain their existing anchored presentation without a new menu backdrop. + +## Menu backdrop design + +The shared backdrop contract in criteria 001.9 through 001.12 extends the +existing bottom-sheet presentation in criterion 001.3. Its technical source is +[Mobile Menu Backdrops](../system-design/mobile-menu-backdrops.md). ## Migrated source detail @@ -90,9 +100,12 @@ Mobile users need the same task controls as desktop without relying on long pres - Changing backend task-move contracts, workflow ordering, or task permissions. - Unlocking or changing a started task's prompt. - Changing repository selection during task creation, workspace-source attachment, file browsing, or desktop and tablet task interaction. +- Changing the backdrop of ordinary dialogs, full-height mobile Quick Chat, + non-menu popovers, or existing Drawer and Sheet surfaces. ## Implementation plans - [Mobile task navigation refinement](../../../plans/mobile-task-navigation-refinement/plan.md) - [SPA blank-screen resilience](../../../plans/spa-blank-screen-resilience/plan.md) - [Mobile repository switcher removal](../../../plans/mobile-repository-switcher-removal/plan.md) +- [Mobile menu backdrops](../../../plans/mobile-menu-backdrops/plan.md) diff --git a/docs/specs/ui/system-design/mobile-menu-backdrops.md b/docs/specs/ui/system-design/mobile-menu-backdrops.md new file mode 100644 index 0000000000..2b921a61f7 --- /dev/null +++ b/docs/specs/ui/system-design/mobile-menu-backdrops.md @@ -0,0 +1,138 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-MOBILE-TASK-NAVIGATION-001 +--- + +# Mobile Menu Backdrops System Design + +## Purpose and boundaries + +The UI system owns the reusable responsive menu presentation in +[Mobile Task Navigation](../requirements/mobile-task-navigation.md). Task, +workspace, session, and integration systems continue to own the actions and +state exposed through these menus. This design covers the backdrop portion of +that existing contract, including non-task consumers of the same primitives. + +## Requirement mapping + +| Acceptance criteria | Design section | +| --- | --- | +| AC-UI-MOBILE-TASK-NAVIGATION-001.3, 001.9 | Shared presentation | +| AC-UI-MOBILE-TASK-NAVIGATION-001.10 | Nested surfaces | +| AC-UI-MOBILE-TASK-NAVIGATION-001.11 | Lifecycle and interaction | +| AC-UI-MOBILE-TASK-NAVIGATION-001.12 | Shared presentation and compatibility | + +## Components and responsibilities + +- `apps/packages/ui/src/dropdown-menu.tsx` and + `apps/packages/ui/src/context-menu.tsx` identify root content with a shared + `mobile-menu-root` class. Submenu content does not receive the marker. +- `apps/web/app/globals.css` owns responsive geometry and backdrop styling, + scoped to the generated positioning wrapper immediately containing that + marked root content. +- `apps/packages/ui/src/drawer.tsx` is the visual reference for the backdrop: + `bg-black/80` and supported `backdrop-blur-xs`. Its implementation stays + unchanged. +- Existing Radix roots own open state, presence, accessibility, dismissal, + focus, and outside interaction. Consumer components retain their handlers. + +## Shared presentation + +Extend the existing `max-width: 639px` menu rule. Generate one decorative +`::before` backdrop on the positioning wrapper whose direct child has +`mobile-menu-root`. Make it opaque only for `data-state="open"`. Use a fixed +viewport inset, the Drawer backdrop utilities, `pointer-events: none`, and a negative stacking +level within the menu wrapper's existing stacking context. Keep the menu and +its children above the backdrop. + +The phone positioning rule removes the wrapper transform and resets Radix's +inline `will-change: transform` hint. Both must be cleared so the fixed +pseudo-element covers the viewport instead of using the wrapper as its +containing block. The backdrop belongs outside the menu's scrolling +content so long menus cannot clip it. Do not apply a filter to the document, +application container, positioning wrapper, or menu content itself. + +Use the shared marker to scope this behavior. Do not add document-wide +open-menu selectors or per-consumer backdrop implementations. No React state, +observer, portal wrapper, event listener, or package dependency is needed. + +## Nested surfaces + +Only root content carries the marker. `DropdownMenuSubContent` and +`ContextMenuSubContent` reuse the root backdrop while preserving their +existing bottom-sheet geometry. Opening or closing a submenu does not create +another menu backdrop or change the root backdrop strength. + +Menus opened inside a Drawer have two independently owned surfaces: the +existing Drawer backdrop behind the drawer and the menu backdrop behind the +menu. Closing the menu removes only its backdrop. Verify the portaled menu +remains above the parent drawer and that nested menu rows are actual hit +targets. + +## Lifecycle and interaction + +The `data-state="open"` selector follows Radix state directly. The backdrop +transitions opacity over 100ms, matching the existing menu exit duration, so +closing content retains dimming while it animates out. Closed force-mounted +content is transparent after that transition. Unmounting removes the positioning +wrapper and its pseudo-element immediately. Crossing the CSS breakpoint removes +or restores the backdrop without changing menu state. + +The pseudo-element is decorative and is absent from the accessibility tree. +It must not receive pointer events or introduce another focus or scroll lock. +Radix continues to handle modal and non-modal menus. In particular, +`MobileWorkspaceSection` passes `modal={false}` to the workspace picker inside +the Home drawer; the backdrop does not change that interaction contract. +Radix's submenu close key returns to the parent menu, while Escape closes the +whole menu hierarchy. Neither operation closes an enclosing Drawer. + +## Mobile composition and compatibility + +The nearest shipped exemplars are `components/kanban/mobile-menu-sheet.tsx` +and `components/task/mobile/mobile-picker-sheet.tsx`. Their dimmed, blurred +backgrounds establish the visual treatment. Contextual menus keep the existing +inset bottom surface because they expose short, temporary choices. + +Entry points, menu hierarchy, and primary actions stay with each consumer. +The current menu content remains the single vertical scroll owner with its +`70dvh` cap, safe-area inset, and 44px minimum rows. The backdrop adds no scroll +region. The action/state model is shared across viewports. + +At 640px and above the backdrop is not generated and the current anchored +menu geometry applies. This boundary intentionally differs from the 768px +application layout breakpoint. Drawer, Sheet, ordinary Dialog, Select, +Popover, and full-height Quick Chat contracts are outside this change. + +The support-gated blur retains the dark layer when filtering is unavailable. +Verify both light and dark themes; the foreground must not inherit blur. + +## Verification + +Browser tests inspect the root positioner's computed `::before` style, not +only a class name. They verify a viewport-covering fixed layer, positive blur +when supported, dimming matching an existing Drawer, negative local stacking, +and pointer transparency. Verify the wrapper's positive stacking level as well +as the backdrop's negative local level. A screenshot confirms that actual page +content is blurred and menu text remains sharp. + +Exercise real Kanban task options, task-row context submenus, and the non-modal +workspace picker. Assert menu actions still work, parent drawers survive menu +dismissal, outside tap and Escape clean up, and document horizontal overflow +remains zero. Count generated backdrops across root and submenu positioners +to catch accidental duplication. Cover 639px, 640px, and an ordinary desktop +width, including resizing while a menu is open. Observe the actual close-state +mutation to verify the backdrop stays generated and fades before Radix removes +the sheet; do not use a fixed sleep to sample the exit frame. + +Use the focused commands and named regressions in the +[work order](../../../plans/mobile-menu-backdrops/task-01-add-menu-backdrops.md). +No unit test is needed for static marker and CSS changes; browser evidence is +the relevant rendering and interaction boundary. + +## Related decisions + +No new ADR is required. This change extends the established shared menu CSS +and Drawer visual treatment without changing component APIs, action ownership, +persistence, or runtime boundaries.