From 446eb550b9d109082270018ad61c782718c50d8d Mon Sep 17 00:00:00 2001 From: Aulma <37032612+Aulma@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:54:56 +0200 Subject: [PATCH] fix(web): align the responsive breakpoints with the sidebar boundary (#2254) --- .../references/kandev-mobile-ui-language.md | 2 +- apps/web/AGENTS.md | 3 +- .../app-status-surface-provider.test.tsx | 12 +++++ .../app-status-surface-provider.tsx | 8 +++- .../config-chat/config-chat-panel.tsx | 32 +++++++++---- .../review/review-dialog-surface.tsx | 6 +-- apps/web/e2e/fixtures/test-base.ts | 17 +++++++ .../e2e/tests/layout/app-status-bar.spec.ts | 25 +++++----- .../layout/compact-desktop-responsive.spec.ts | 18 ++++++- .../layout/pane-persistence-tablet.spec.ts | 47 +++++++++---------- .../layout/ws-connectivity-warning.spec.ts | 8 +++- .../review/review-sidebar-resize.spec.ts | 21 +++++++++ .../hooks/use-responsive-breakpoint.test.ts | 5 +- apps/web/hooks/use-responsive-breakpoint.ts | 10 ++-- docs/specs/ui/mobile-task-navigation.md | 2 +- 15 files changed, 151 insertions(+), 65 deletions(-) diff --git a/.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md b/.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md index 5e59f53be6..d4ebe18a85 100644 --- a/.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md +++ b/.agents/skills/mobile-parity/references/kandev-mobile-ui-language.md @@ -32,7 +32,7 @@ Choose based on task frequency and content depth. A frequently revisited primary ## Composition and Feel -- Phone breakpoint is below 640px. Use `useResponsiveBreakpoint`; it distinguishes phone, tablet, compact desktop, and full desktop using width plus pointer mode. +- Phone breakpoint is below 768px, matching the width at which the desktop sidebar appears. Use `useResponsiveBreakpoint`; it distinguishes phone, tablet, compact desktop, and full desktop using width plus pointer mode. Tablet is a coarse-pointer fallback between 768px and 1024px. The bottom-sheet treatment for Radix menus in `app/globals.css` is deliberately narrower at 640px — pick the boundary that matches what you are changing. - Reuse `@kandev/ui` primitives. `Drawer` is the standard bottom surface; `Sheet` remains useful for tablet/desktop side panels. - Use inset, rounded card surfaces with restrained borders/backgrounds and shadow, matching nearby components. Prefer existing component classes over copying a frozen class list. - Give new primary touch actions, standalone icon buttons, and menu rows an actual hitbox of at least 44px in the active dimension (`min-h-11`, `h-11 w-11`). Existing compact chrome such as `MobilePillButton` is a deliberate density exception, not a sizing precedent for unrelated controls; do not claim surrounding spacing enlarges its hitbox. Provide pressed feedback such as the existing short `active:scale-*` treatment where nearby controls use it. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 4dfe9dd9b6..02d2ece2da 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -26,7 +26,8 @@ import { Dialog } from "@kandev/ui/dialog"; ### Responsive and touch surfaces -- Use `hooks/use-responsive-breakpoint.ts` for application layout decisions. Its phone boundary is 640px and it also models tablet, compact desktop, full desktop, and pointer precision; do not substitute the UI package's generic `useIsMobile` hook. +- Use `hooks/use-responsive-breakpoint.ts` for application layout decisions. Its mobile boundary matches the sidebar's 768px `md` boundary, and it also models tablet, compact desktop, full desktop, and pointer precision; do not substitute the UI package's generic `useIsMobile` hook. Tablet is a coarse-pointer fallback between `md` and `lg`, so no fine-pointer width reports it — a spec that needs the tablet layout has to emulate touch. +- When a Tailwind visibility class gates the same surface the hook picks, both must use the same boundary. A `sm:` class paired with a hook-driven mobile branch leaves 640-767px in a state neither side renders. - Use `useTouchDrawer` when a hover/popover disclosure needs a coarse-pointer `Drawer` alternative. Width-based phone composition and pointer-based disclosure behavior are related but not interchangeable. - Existing Radix DropdownMenu and ContextMenu surfaces receive inset, safe-area-aware bottom-sheet treatment below 640px in `app/globals.css`. Reuse those primitives for contextual actions and add focused coverage for long or nested menus instead of creating a parallel mobile menu. - Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements. diff --git a/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx b/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx index 9af3583a67..67689bd895 100644 --- a/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx +++ b/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx @@ -97,6 +97,18 @@ describe("AppStatusSurfaceProvider", () => { expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).textContent).toBe("true"); }); + it("keeps the drawer trigger visible across the whole mobile band", () => { + // The drawer replaces the inline bar below the hook's mobile boundary + // (768px). A narrower visibility class would hide the only trigger on + // topbar routes, leaving 640-767px with no status surface at all. + responsiveState.breakpoint = "mobile"; + renderSurface(); + + const trigger = screen.getByTestId(STATUS_DRAWER_TRIGGER_TEST_ID); + expect(trigger.className).toContain("md:hidden"); + expect(trigger.className).not.toContain("sm:hidden"); + }); + it("hides both presentations when the app-status-bar feature is disabled", () => { responsiveState.breakpoint = "mobile"; featureState.appStatusBar = false; diff --git a/apps/web/components/app-status-bar/app-status-surface-provider.tsx b/apps/web/components/app-status-bar/app-status-surface-provider.tsx index 4c5a23d640..e2624e175f 100644 --- a/apps/web/components/app-status-bar/app-status-surface-provider.tsx +++ b/apps/web/components/app-status-bar/app-status-surface-provider.tsx @@ -98,8 +98,14 @@ export function AppStatusDrawerTrigger({ ); } +/** + * The trigger must be visible exactly where `useDrawerSurface` picks the drawer + * over the inline bar, or the drawer becomes unreachable and the status surface + * disappears. `md:hidden` tracks the hook's mobile boundary; the connection-only + * variant additionally covers the tablet clause up to the desktop boundary. + */ function drawerTriggerVisibilityClass(connectionOnly: boolean) { - return connectionOnly ? "lg:hidden" : "sm:hidden"; + return connectionOnly ? "lg:hidden" : "md:hidden"; } export function AppStatusSurfaceProvider({ children }: { children: ReactNode }) { diff --git a/apps/web/components/config-chat/config-chat-panel.tsx b/apps/web/components/config-chat/config-chat-panel.tsx index 12ae2dac14..52b9fd87e9 100644 --- a/apps/web/components/config-chat/config-chat-panel.tsx +++ b/apps/web/components/config-chat/config-chat-panel.tsx @@ -24,7 +24,15 @@ function useConfigChatPanelStore() { ); } -function PanelHeader({ onExpand, onClose }: { onExpand: () => void; onClose: () => void }) { +function PanelHeader({ + expandDisabled, + onExpand, + onClose, +}: { + expandDisabled: boolean; + onExpand: () => void; + onClose: () => void; +}) { const { t } = useTranslation(); return (
@@ -35,15 +43,18 @@ function PanelHeader({ onExpand, onClose }: { onExpand: () => void; onClose: ()
- + + + {t("configChat:openInQuickChat")} @@ -172,6 +183,7 @@ export const ConfigChatPanel = memo(function ConfigChatPanel({
panel.handleOpenChange(false)} /> diff --git a/apps/web/components/review/review-dialog-surface.tsx b/apps/web/components/review/review-dialog-surface.tsx index a64243373f..2435cb230c 100644 --- a/apps/web/components/review/review-dialog-surface.tsx +++ b/apps/web/components/review/review-dialog-surface.tsx @@ -96,7 +96,7 @@ export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) { return ( @@ -126,7 +126,7 @@ export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) {
diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index a34a00c126..1b9098be77 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -26,6 +26,7 @@ export type SeedData = { export const test = backendFixture.extend< { testPage: Page; + tabletTestPage: Page; prCapture: PrAssetCapture; /** * Auto fixture that resets integration mock state and any persisted @@ -220,6 +221,22 @@ export const test = backendFixture.extend< await context.close(); }, + tabletTestPage: async ({ browser, backend, testPage }, use) => { + // Depend on testPage so its per-test backend and settings reset runs before + // this specialized context is created. + void testPage; + const context = await browser.newContext({ + baseURL: backend.frontendUrl, + viewport: { width: 900, height: 900 }, + hasTouch: true, + isMobile: false, + }); + const page = await context.newPage(); + await setupPage(page, backend); + await use(page); + await context.close(); + }, + // PR asset capture — gated behind CAPTURE_PR_ASSETS env var. // When enabled, provides screenshot/recording helpers for PR descriptions. // Destructure in tests that need it: { testPage, prCapture } diff --git a/apps/web/e2e/tests/layout/app-status-bar.spec.ts b/apps/web/e2e/tests/layout/app-status-bar.spec.ts index a0157881de..6e365d1c65 100644 --- a/apps/web/e2e/tests/layout/app-status-bar.spec.ts +++ b/apps/web/e2e/tests/layout/app-status-bar.spec.ts @@ -78,22 +78,25 @@ test.describe("App status bar", () => { await expectStatusBarAfterSidebar(testPage); }); - test("fills available width when responsive layout hides sidebar", async ({ testPage }) => { + test("hands the status surface to the drawer once the sidebar is hidden", async ({ + testPage, + }) => { + // The sidebar is `hidden md:block` and `useResponsiveBreakpoint` switches to + // mobile composition at the same 768px boundary, so no width renders the + // inline bar without the sidebar beside it. Below the boundary the drawer + // trigger *is* the status surface, and it has to stay reachable across the + // whole band — 700px is the width where a 640px trigger class used to hide it. await testPage.setViewportSize({ width: 700, height: 900 }); - await testPage.goto("/"); + await testPage.goto("/stats"); - const bar = testPage.getByTestId("app-status-bar"); - await expect(bar).toBeVisible(); await expect(testPage.getByTestId("app-sidebar")).toBeHidden(); + await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0); - const [barBox, viewport] = await Promise.all([ - bar.boundingBox(), - testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), - ]); - if (!barBox) throw new Error("app status bar has no bounding box"); + const trigger = testPage.getByTestId("app-status-drawer-trigger"); + await expect(trigger).toBeVisible(); - expect(Math.abs(barBox.x)).toBeLessThanOrEqual(PIXEL_TOLERANCE); - expect(Math.abs(barBox.width - viewport.width)).toBeLessThanOrEqual(PIXEL_TOLERANCE); + await trigger.click(); + await expect(testPage.getByTestId("app-status-drawer")).toBeVisible(); }); test("persists a modifier-mouse move across the spacer", async ({ testPage }) => { diff --git a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts index f8ccb5fecd..b0939d0d2e 100644 --- a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts +++ b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts @@ -9,7 +9,7 @@ const COMPACT_DESKTOP_VIEWPORT = { width: 900, height: 800 }; useRegularMode(); test.describe("compact desktop responsive layout", () => { - test("task page keeps the Dockview workbench at half-screen width", async ({ + test("task page hands off from compact desktop to mobile below the sidebar boundary", async ({ testPage, apiClient, seedData, @@ -45,6 +45,18 @@ test.describe("compact desktop responsive layout", () => { await expect(tabs.filter({ hasText: "Files" })).toBeVisible(); await expect(tabs.filter({ hasText: "Changes" })).toBeVisible(); await expect(tabs.filter({ hasText: "Terminal" })).toBeVisible(); + + await testPage.setViewportSize({ width: 700, height: 800 }); + + await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible(); + await expect(testPage.getByTestId("dockview-task-layout")).toHaveCount(0); + await expect(testPage.getByTestId("tablet-task-layout")).toHaveCount(0); + await expect(testPage.getByTestId("app-sidebar")).toBeHidden(); + await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible(); + await expect(session.activeChat()).toBeVisible(); + + await testPage.getByRole("button", { name: "Status", exact: true }).click(); + await expect(testPage.getByTestId("app-status-drawer")).toBeVisible(); }); test("kanban windows readable desktop lanes without leaking subtask hierarchy", async ({ @@ -125,8 +137,10 @@ test.describe("compact desktop responsive layout", () => { await testPage.setViewportSize({ width: 1600, height: 800 }); await expect(testPage.getByTestId("desktop-kanban-stage-navigator")).toHaveCount(0); + // Below the 768px sidebar boundary the board uses mobile composition; the + // tablet layout is a coarse-pointer fallback and is not reachable here. await testPage.setViewportSize({ width: 700, height: 800 }); - await expect(testPage.getByTestId("tablet-kanban-layout")).toBeVisible(); + await expect(testPage.getByTestId("mobile-kanban-layout")).toBeVisible(); await expect(testPage.getByTestId("desktop-kanban-stage-navigator")).toHaveCount(0); }); }); diff --git a/apps/web/e2e/tests/layout/pane-persistence-tablet.spec.ts b/apps/web/e2e/tests/layout/pane-persistence-tablet.spec.ts index 5108b6ac77..90c558f4bb 100644 --- a/apps/web/e2e/tests/layout/pane-persistence-tablet.spec.ts +++ b/apps/web/e2e/tests/layout/pane-persistence-tablet.spec.ts @@ -4,10 +4,7 @@ import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { SessionPage } from "../../pages/session-page"; -// The responsive breakpoint logic treats width < 768 with fine pointer as -// "tablet"; width >= 768 with fine pointer is compactDesktop. Headless -// Chromium reports fine pointer, so a true tablet layout needs width < 768. -const TABLET_VIEWPORT = { width: 700, height: 900 }; +const TABLET_VIEWPORT = { width: 900, height: 900 }; async function openTabletTask( page: Page, @@ -16,6 +13,7 @@ async function openTabletTask( title: string, ): Promise { await page.setViewportSize(TABLET_VIEWPORT); + pwExpect(await page.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, title, @@ -43,30 +41,30 @@ async function readStoredLayout(page: Page, id: string): Promise { test("tablet left/right split persists across reload", async ({ - testPage, + tabletTestPage, apiClient, seedData, }) => { - const session = await openTabletTask(testPage, apiClient, seedData, "Tablet persist"); + const session = await openTabletTask(tabletTestPage, apiClient, seedData, "Tablet persist"); // Drive the resize-panels library directly by overwriting the stored // layout — the underlying drag handle is a complex pointer-events target // that's flaky to grab in headless. The stored layout drives the next // render; this test verifies the round-trip persistence contract AND // that the rendered panels match the stored proportions. - await testPage.evaluate(() => { - window.localStorage.setItem("task-layout-tablet-v1", JSON.stringify({ left: 65, right: 35 })); + await tabletTestPage.evaluate(() => { + window.localStorage.setItem("task-layout-tablet-v1", JSON.stringify({ left: 55, right: 45 })); }); - await testPage.reload(); + await tabletTestPage.reload(); await session.waitForLoad(); - await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible({ timeout: 10_000 }); + await expect(tabletTestPage.getByTestId("tablet-task-layout")).toBeVisible({ timeout: 10_000 }); // localStorage round-trip: react-resizable-panels writes its own // normalized version on layout, so allow a small tolerance. - const stored = await readStoredLayout(testPage, "task-layout-tablet-v1"); + const stored = await readStoredLayout(tabletTestPage, "task-layout-tablet-v1"); pwExpect(stored).not.toBeNull(); - pwExpect(Math.abs((stored?.left ?? 0) - 65)).toBeLessThanOrEqual(2); - pwExpect(Math.abs((stored?.right ?? 0) - 35)).toBeLessThanOrEqual(2); + pwExpect(Math.abs((stored?.left ?? 0) - 55)).toBeLessThanOrEqual(2); + pwExpect(Math.abs((stored?.right ?? 0) - 45)).toBeLessThanOrEqual(2); // The localStorage round-trip above is the persistence contract; // verifying rendered pixel widths in `react-resizable-panels` requires @@ -75,44 +73,43 @@ test.describe("Tablet pane persistence", () => { }); test("invalid stored layout is replaced with a valid default", async ({ - testPage, + tabletTestPage, apiClient, seedData, }) => { - await testPage.setViewportSize(TABLET_VIEWPORT); - await testPage.goto("/"); - await testPage.evaluate(() => { + await tabletTestPage.goto("/"); + await tabletTestPage.evaluate(() => { window.localStorage.setItem("task-layout-tablet-v1", '{"left":2}'); }); - const session = await openTabletTask(testPage, apiClient, seedData, "Tablet fallback"); + const session = await openTabletTask(tabletTestPage, apiClient, seedData, "Tablet fallback"); void session; // After load, onLayoutChanged replaces the invalid stub with the // rendered (valid) layout. Verify the persisted value is now valid: // both panel IDs present and >= MIN_PANEL_PERCENT (5). - const stored = await readStoredLayout(testPage, "task-layout-tablet-v1"); + const stored = await readStoredLayout(tabletTestPage, "task-layout-tablet-v1"); pwExpect(stored).not.toBeNull(); pwExpect(stored?.left).toBeGreaterThanOrEqual(5); pwExpect(stored?.right).toBeGreaterThanOrEqual(5); }); test("tablet right-panel inner split accepts values below the old 30% floor", async ({ - testPage, + tabletTestPage, apiClient, seedData, }) => { - await openTabletTask(testPage, apiClient, seedData, "Tablet top shrink"); + await openTabletTask(tabletTestPage, apiClient, seedData, "Tablet top shrink"); // The right-panel internal split now allows minSize=15. Round-trip via // localStorage: write a 20/80 layout (below the old 30% floor) and // verify it survives the reload — would have been rejected before. - await testPage.evaluate(() => { + await tabletTestPage.evaluate(() => { window.localStorage.setItem("task-layout-right-v2", JSON.stringify({ top: 20, bottom: 80 })); }); - await testPage.reload(); - await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible({ timeout: 10_000 }); + await tabletTestPage.reload(); + await expect(tabletTestPage.getByTestId("tablet-task-layout")).toBeVisible({ timeout: 10_000 }); - const stored = await readStoredLayout(testPage, "task-layout-right-v2"); + const stored = await readStoredLayout(tabletTestPage, "task-layout-right-v2"); pwExpect(stored).not.toBeNull(); pwExpect(Math.abs((stored?.top ?? 0) - 20)).toBeLessThanOrEqual(2); pwExpect(Math.abs((stored?.bottom ?? 0) - 80)).toBeLessThanOrEqual(2); diff --git a/apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts b/apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts index 78139152f6..0cda9d4b9f 100644 --- a/apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts +++ b/apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts @@ -49,7 +49,13 @@ test.describe("WebSocket connectivity warning", () => { await expect(warning).toHaveCount(0); }); - test("keeps a connection-only warning reachable on tablet widths", async ({ testPage }) => { + // 700px is mobile composition since the hook's boundary moved to 768px, so + // this covers the drawer fallback below the sidebar boundary. The + // `isTablet && connectionOnly` clause above it needs a coarse pointer between + // 768px and 1024px, which no Playwright project emulates yet. + test("keeps a connection-only warning reachable below the sidebar boundary", async ({ + testPage, + }) => { await testPage.setViewportSize({ width: 700, height: 900 }); await testPage.goto("/stats"); await setAppStatusBarEnabled(testPage, false); diff --git a/apps/web/e2e/tests/review/review-sidebar-resize.spec.ts b/apps/web/e2e/tests/review/review-sidebar-resize.spec.ts index 8296986d92..2f2079536c 100644 --- a/apps/web/e2e/tests/review/review-sidebar-resize.spec.ts +++ b/apps/web/e2e/tests/review/review-sidebar-resize.spec.ts @@ -152,4 +152,25 @@ test.describe("Review dialog sidebar resize", () => { .toBeGreaterThanOrEqual(339); expectWidthNear(await getSidebarWidth(sidebar), 340); }); + + test("hides desktop sidebar chrome across the mobile band", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedReviewTask(testPage, apiClient, seedData); + await testPage.setViewportSize({ width: 700, height: 900 }); + await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible(); + await testPage.getByRole("button", { name: /Changes$/ }).click(); + const changesPanel = testPage.getByTestId("mobile-changes-panel"); + await expect(changesPanel).toBeVisible(); + await changesPanel.getByRole("button", { name: "Review", exact: true }).click(); + + const dialog = testPage.getByRole("dialog", { name: "Review Changes" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByTestId("review-dialog-sidebar")).toBeHidden(); + await expect(dialog.getByTestId("review-dialog-sidebar-resize")).toBeHidden(); + await expect(dialog.getByTestId("review-file-actions").first()).toHaveCount(0); + await expect.poll(async () => Math.round((await dialog.boundingBox())?.width ?? 0)).toBe(700); + }); }); diff --git a/apps/web/hooks/use-responsive-breakpoint.test.ts b/apps/web/hooks/use-responsive-breakpoint.test.ts index 6a15b8c538..08d21c7b1f 100644 --- a/apps/web/hooks/use-responsive-breakpoint.test.ts +++ b/apps/web/hooks/use-responsive-breakpoint.test.ts @@ -100,10 +100,11 @@ describe("useResponsiveBreakpoint", () => { expect(result.current.usesDesktopWorkbench).toBe(false); }); - it("updates workbench mode when crossing compact desktop boundary", () => { - setViewport(640, "fine"); + it("uses mobile composition until the desktop sidebar becomes available", () => { + setViewport(767, "fine"); const { result } = renderHook(() => useResponsiveBreakpoint()); + expect(result.current.breakpoint).toBe("mobile"); expect(result.current.usesDesktopWorkbench).toBe(false); notifyResize(768, "fine"); diff --git a/apps/web/hooks/use-responsive-breakpoint.ts b/apps/web/hooks/use-responsive-breakpoint.ts index fd9dd00288..3c52e65658 100644 --- a/apps/web/hooks/use-responsive-breakpoint.ts +++ b/apps/web/hooks/use-responsive-breakpoint.ts @@ -1,7 +1,6 @@ import * as React from "react"; -const MOBILE_BREAKPOINT = 640; -const COMPACT_DESKTOP_BREAKPOINT = 768; +const MOBILE_BREAKPOINT = 768; const DESKTOP_BREAKPOINT = 1024; export type Breakpoint = "mobile" | "tablet" | "compactDesktop" | "desktop"; @@ -21,9 +20,7 @@ function getBreakpoint(width: number, isFinePointer: boolean): Breakpoint { if (width < MOBILE_BREAKPOINT) { return "mobile"; } - // Fine-pointer devices below 768px stay on the tablet layout; that range is - // too narrow to host the full workbench even with a mouse. - if (width >= COMPACT_DESKTOP_BREAKPOINT && width < DESKTOP_BREAKPOINT && isFinePointer) { + if (width < DESKTOP_BREAKPOINT && isFinePointer) { return "compactDesktop"; } if (width < DESKTOP_BREAKPOINT) { @@ -91,8 +88,7 @@ function subscribeBreakpoint(callback: () => void): () => void { } const mediaQueries = [ `(max-width: ${MOBILE_BREAKPOINT - 1}px)`, - `(min-width: ${MOBILE_BREAKPOINT}px) and (max-width: ${COMPACT_DESKTOP_BREAKPOINT - 1}px)`, - `(min-width: ${COMPACT_DESKTOP_BREAKPOINT}px) and (max-width: ${DESKTOP_BREAKPOINT - 1}px)`, + `(min-width: ${MOBILE_BREAKPOINT}px) and (max-width: ${DESKTOP_BREAKPOINT - 1}px)`, `(min-width: ${DESKTOP_BREAKPOINT}px)`, "(pointer: fine)", ]; diff --git a/docs/specs/ui/mobile-task-navigation.md b/docs/specs/ui/mobile-task-navigation.md index 3d927851e8..987f717a48 100644 --- a/docs/specs/ui/mobile-task-navigation.md +++ b/docs/specs/ui/mobile-task-navigation.md @@ -14,7 +14,7 @@ Mobile users need the same task controls as desktop without relying on long pres - Task action controls are visible and touch-reachable on mobile. - Mobile task actions preserve desktop capabilities, including same-workflow **Move to**, cross-workflow **Send to workflow**, linking, pinning, renaming, coloring, archiving, and deleting when those actions are available on desktop. -- Context and dropdown action menus below the app's 640px mobile breakpoint stay within the viewport, use bottom-sheet presentation, contain their own vertical overflow, respect the bottom safe area, and provide touch targets at least 44px high. +- Context and dropdown action menus below 640px stay within the viewport, use bottom-sheet presentation, contain their own vertical overflow, respect the bottom safe area, and provide touch targets at least 44px high. - Mobile Kanban renders one focused workflow and one focused step at a time when the user has several workflows. - Workflow is a primary mobile Kanban navigation dimension, not a setting hidden in the secondary display menu. The current workflow and step are always visible together in the board navigation control, including when only one workflow exists. - 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.