diff --git a/docs/design.md b/docs/design.md index fe60cdc53b..a336b80a64 100644 --- a/docs/design.md +++ b/docs/design.md @@ -150,18 +150,20 @@ One row off the rail makes the whole card look unconsidered. Compact-first. The small case is designed; the large case adds chrome around it. -The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically: +Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window. + +The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern: - On compact: full-screen list with `` at the top. Tapping a row pushes a full-screen detail with its own `` that returns to the list. -- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with ``, ``, and ``. +- On wide layouts: a sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with ``, ``, and ``. The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes. The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop. -On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths. +On a narrow window using the wide layout, app navigation yields when the remaining width cannot accommodate the content topology. Settings prefers a 720px split (320px list + 400px detail); its list shrinks first to keep forms readable without reducing the detail below 400px. Explorer reserves at least 240px plus a 360px workspace. These are local space budgets, not compact breakpoints. The app sidebar shrinks from its preferred 320px toward a 200px minimum to leave a 400px center target; its preferred width fits at 720px. Temporary width clamps are render-only; widening restores the user's saved sidebar widths. -Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate. +Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target. diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index 4d7afbd4b9..3ec921a663 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -479,3 +479,76 @@ test.describe("Settings — compact master-detail", () => { await expect(page.getByTestId("welcome-direct-connection")).toBeVisible(); }); }); + +async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) { + await page.setViewportSize({ width, height: 951 }); + const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true }); + const settings = page + .getByRole("button", { name: "Settings", exact: true }) + .filter({ visible: true }); + await expect(history).toBeVisible(); + await expect(settings).toBeVisible(); + return { + width, + history: await history.evaluate((el) => el.getBoundingClientRect().height), + settings: await settings.evaluate((el) => el.getBoundingClientRect().height), + }; +} + +async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) { + await page.setViewportSize({ width, height: 951 }); + const sidebar = page.getByTestId("settings-sidebar"); + const detail = page.getByTestId("settings-detail-pane"); + await expect(sidebar).toBeVisible(); + await expect(detail).toBeVisible(); + return { + width, + sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width), + detail: await detail.evaluate((el) => el.getBoundingClientRect().width), + }; +} + +async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) { + const wide = await measureDockedNavigation(page, 951); + for (const width of [600, 669, 719, 720]) { + const narrow = await measureDockedNavigation(page, width); + expect + .soft(narrow.history, `History keeps its wide-layout density at ${width}`) + .toBeCloseTo(wide.history, 0); + expect + .soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`) + .toBeCloseTo(wide.settings, 0); + } +} + +async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) { + const reference = await measureSettingsSplit(page, 720); + for (const width of [600, 669, 719]) { + const narrow = await measureSettingsSplit(page, width); + expect + .soft(narrow.detail, `Settings preserves its detail budget at ${width}`) + .toBeGreaterThanOrEqual(reference.detail); + expect + .soft(narrow.sidebar, `Settings navigation yields width at ${width}`) + .toBeLessThan(reference.sidebar); + } + const wide = await measureSettingsSplit(page, 951); + expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0); + expect(wide.detail).toBeGreaterThan(reference.detail); +} + +test("docked navigation keeps its density and Settings preserves reading width", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" }); + await gotoWorkspace(page, workspace.workspaceId); + await expectConsistentDockedNavigation(page); + await openSettings(page); + await openSettingsSection(page, "appearance"); + await expectAppearanceContent(page); + await expectSettingsDetailKeepsReadingWidth(page); + await page.setViewportSize({ width: 599, height: 951 }); + await expectSettingsSidebarHidden(page); + await expectAppearanceContent(page); +}); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index d00b6f7790..c4ef45cb3f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -41,7 +41,7 @@ import { useWorkspaceAttachmentsStore, } from "@/attachments/workspace-attachments-store"; import type { UserMessageImageAttachment } from "@/types/stream"; -import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { buildWorkspaceTabPersistenceKey, @@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({ const editingSession = useComposerEditingSession("chat"); const isComposerFullscreen = useComposerFullscreen(editingSession); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( - COMPACT_FORM_FACTOR_WIDTH, + COMPACT_CONTENT_WIDTH, { initialIsBelow: isCompactFormFactor }, ); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 96aee0e828..739be45ee9 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -11,10 +11,11 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56; export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36; export const HEADER_TOP_PADDING_MOBILE = 8; -export const COMPACT_FORM_FACTOR_WIDTH = 500; +// Measured composer width; independent of the window's compact/wide breakpoint. +export const COMPACT_CONTENT_WIDTH = 500; -// Settings uses the canonical desktop list + detail layout. Its sidebar and -// detail target must fit together before it can share width with app navigation. +// Settings navigation can shrink below its preferred width to preserve the detail +// minimum. Both targets must fit before Settings shares width with app navigation. export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320; export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400; export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH = @@ -33,7 +34,7 @@ export { getIsElectronMac as getIsElectronRuntimeMac, } from "./platform"; -/** Below 600 points, sidebars overlay the workspace. Medium and wider layouts dock them. */ +/** Below md (600 points), sidebars overlay the workspace; wider layouts can dock them. */ export function isCompactBreakpoint(breakpoint: string | undefined): boolean { return breakpoint === "xs" || breakpoint === "sm"; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 8139459b2b..712d9d65a1 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -44,7 +44,7 @@ import { useAgentDescriptor } from "@/agent-presentation"; import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; -import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; @@ -1512,7 +1512,7 @@ function ActiveAgentComposer({ }) { const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( - COMPACT_FORM_FACTOR_WIDTH, + COMPACT_CONTENT_WIDTH, { initialIsBelow: isCompactFormFactor }, ); const paneContext = usePaneContext(); diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index b45b745212..78425bf20d 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -122,7 +122,11 @@ import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-p import { HostVoicePage } from "@/screens/settings/voice/voice-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; -import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { + SETTINGS_DESKTOP_DETAIL_MIN_WIDTH, + SETTINGS_DESKTOP_SIDEBAR_WIDTH, + useIsCompactFormFactor, +} from "@/constants/layout"; import { densityIconBox, densityIconSize } from "@/styles/density"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { @@ -1544,12 +1548,14 @@ const desktopStyles = StyleSheet.create({ }, contentPane: { flex: 1, + minWidth: SETTINGS_DESKTOP_DETAIL_MIN_WIDTH, }, }); const sidebarStyles = StyleSheet.create((theme) => ({ desktopContainer: { width: SETTINGS_DESKTOP_SIDEBAR_WIDTH, + flexShrink: 1, borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index 407faafdf6..96510c59f2 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -6,8 +6,7 @@ StyleSheet.configure({ breakpoints: { xs: 0, sm: 576, - medium: 600, - md: 720, + md: 600, lg: 992, xl: 1200, }, @@ -22,7 +21,6 @@ type AppThemes = typeof REGISTERED_THEMES; interface AppBreakpoints { xs: number; sm: number; - medium: number; md: number; lg: number; xl: number;