fix(web): align the responsive breakpoints with the sidebar boundary (#2254)
This commit is contained in:
@@ -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.
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 (
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b bg-muted/30 pl-3">
|
||||
@@ -35,15 +43,18 @@ function PanelHeader({ onExpand, onClose }: { onExpand: () => void; onClose: ()
|
||||
<div className="flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-11 w-11 cursor-pointer rounded-none"
|
||||
onClick={onExpand}
|
||||
aria-label={t("configChat:openInQuickChat")}
|
||||
>
|
||||
<IconArrowsMaximize className="h-4 w-4" />
|
||||
</Button>
|
||||
<span tabIndex={expandDisabled ? 0 : -1} className="inline-flex">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-11 w-11 cursor-pointer rounded-none"
|
||||
onClick={onExpand}
|
||||
aria-label={t("configChat:openInQuickChat")}
|
||||
disabled={expandDisabled}
|
||||
>
|
||||
<IconArrowsMaximize className="h-4 w-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("configChat:openInQuickChat")}</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -172,6 +183,7 @@ export const ConfigChatPanel = memo(function ConfigChatPanel({
|
||||
<ConfigChatFloatingActionsHost setHost={setFloatingActionsHost} />
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[inherit]">
|
||||
<PanelHeader
|
||||
expandDisabled={panel.isStarting}
|
||||
onExpand={panel.handleExpand}
|
||||
onClose={() => panel.handleOpenChange(false)}
|
||||
/>
|
||||
|
||||
@@ -96,7 +96,7 @@ export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="!max-w-[100vw] !w-[100vw] sm:!max-w-[80vw] sm:!w-[80vw] max-h-[85vh] h-[85vh] p-0 gap-0 flex flex-col shadow-2xl"
|
||||
className="!max-w-[100vw] !w-[100vw] md:!max-w-[80vw] md:!w-[80vw] max-h-[85vh] h-[85vh] p-0 gap-0 flex flex-col shadow-2xl"
|
||||
showCloseButton={false}
|
||||
overlayClassName="bg-black/40"
|
||||
>
|
||||
@@ -126,7 +126,7 @@ export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) {
|
||||
<div key={state.reviewSourceKey} ref={splitRowRef} className="flex min-h-0 flex-1">
|
||||
<div
|
||||
data-testid="review-dialog-sidebar"
|
||||
className="hidden flex-shrink-0 flex-col overflow-hidden border-r border-border sm:flex"
|
||||
className="hidden flex-shrink-0 flex-col overflow-hidden border-r border-border md:flex"
|
||||
style={{ width: `${sidebar.width}px` }}
|
||||
>
|
||||
<ReviewFileTree
|
||||
@@ -146,7 +146,7 @@ export function ReviewDialogSurface(props: ReviewDialogSurfaceProps) {
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Resize file list"
|
||||
className="group relative hidden w-1 flex-shrink-0 cursor-col-resize bg-border p-0 transition-colors hover:bg-primary sm:block"
|
||||
className="group relative hidden w-1 flex-shrink-0 cursor-col-resize bg-border p-0 transition-colors hover:bg-primary md:block"
|
||||
{...sidebar.resizeHandleProps}
|
||||
>
|
||||
<span className="absolute inset-y-0 -left-1 -right-1" />
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<SessionPage> {
|
||||
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<Record<string,
|
||||
|
||||
test.describe("Tablet pane persistence", () => {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)",
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user