fix(web): align the responsive breakpoints with the sidebar boundary (#2254)

This commit is contained in:
Aulma
2026-08-04 22:54:56 +02:00
committed by GitHub
parent 8bca38957f
commit 446eb550b9
15 changed files with 151 additions and 65 deletions
@@ -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
View File
@@ -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" />
+17
View File
@@ -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");
+3 -7
View File
@@ -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)",
];
+1 -1
View File
@@ -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.