diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index 12c83e19e..7ce9a471d 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -480,6 +480,11 @@ body { } /* The split workspace is one elevated surface; its panes only own the divider. */ +.workspace-root[data-workspace-mode="split"] .workspace-frame { + margin-left: calc(var(--spacing) * 3 + env(safe-area-inset-left)); + margin-right: calc(var(--spacing) * 3 + env(safe-area-inset-right)); +} + .workspace-frame .plan-document { box-shadow: none; } @@ -492,6 +497,10 @@ body { --panel-direction: -1; } +.workspace-root[data-workspace-mode="split"] .workspace-frame > main { + min-width: 9rem; +} + .motion-panel { opacity: 0; transform: translateX(calc(var(--panel-distance) * var(--panel-direction, 1))); diff --git a/apps/web/src/workspace-model.test.ts b/apps/web/src/workspace-model.test.ts index e95c60f9d..9fb590cee 100644 --- a/apps/web/src/workspace-model.test.ts +++ b/apps/web/src/workspace-model.test.ts @@ -20,8 +20,8 @@ const documentPresentation = { type: "document" } as const; function mediaAt(width: number): MatchMedia { return query => { - let maximum = /\(max-width: (\d+)px\)/.exec(query); - return { matches: maximum !== null && width <= Number(maximum[1]) }; + let boundary = /\(width < (\d+(?:\.\d+)?)px\)/.exec(query); + return { matches: boundary !== null && width < Number(boundary[1]) }; }; } @@ -95,11 +95,9 @@ describe("adaptive workspace", () => { expect(workspaceHeadingId("plan", "child-room")).toBe("child-room-workspace-plan-heading"); }); - it("classifies the media queries production reads at each boundary", () => { - expect([1023, 1024].map(width => workspaceMode(mediaAt(width)))).toEqual([ - "compact", - "split", - ]); + it("classifies fractional widths on either side of the media boundary", () => { + expect([499.5, 500].map(width => workspaceMode(mediaAt(width)))) + .toEqual(["compact", "split"]); }); it("closing Chat leaves the visible document view untouched", () => { @@ -137,7 +135,7 @@ describe("adaptive workspace", () => { expect(presentWorkspace(state, "split", "plan").chatVisible).toBe(true); }); - it("shows Chat as the only compact destination on tablets", () => { + it("shows Chat as the only compact destination", () => { let state: WorkspaceState = { chatOpen: true, desktopChatOpen: true, diff --git a/apps/web/src/workspace-model.ts b/apps/web/src/workspace-model.ts index 0376cd4d7..d795510eb 100644 --- a/apps/web/src/workspace-model.ts +++ b/apps/web/src/workspace-model.ts @@ -29,7 +29,7 @@ export type WorkspaceEvent = | { type: "set-chat"; open: boolean } | { type: "set-desktop-chat"; open: boolean }; -export const WORKSPACE_MEDIA = ["(max-width: 1023px)"] as const; +export const WORKSPACE_MEDIA = ["(width < 500px)"] as const; export function initialWorkspaceState( profile: WorkspaceProfile, diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index a1e9733b9..a84dca657 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -397,6 +397,16 @@ export function Workspace(
{ + if (!(event.target instanceof HTMLElement)) return; + let control = event.target.getBoundingClientRect(); + let toolbar = event.currentTarget.getBoundingClientRect(); + if (control.left < toolbar.left) { + event.currentTarget.scrollLeft += Math.floor(control.left - toolbar.left); + } else if (control.right > toolbar.right) { + event.currentTarget.scrollLeft += Math.ceil(control.right - toolbar.right); + } + }} > {chat && !presentation.chatVisible && ( { - let page = await join("ana", { viewport: { width: 649, height: 900 } }); + let page = await join("ana", { viewport: { width: 390, height: 900 } }); await openJevWire(page, room); await page.getByRole("navigation", { name: "Workspace view" }) .getByRole("button", { name: /^Chat/ }).click(); diff --git a/e2e/responsive-workspace.e2e.ts b/e2e/responsive-workspace.e2e.ts index 28ca17c7a..a5e8f41b3 100644 --- a/e2e/responsive-workspace.e2e.ts +++ b/e2e/responsive-workspace.e2e.ts @@ -107,6 +107,124 @@ test("a representative compact phone exposes one mounted destination at a time", await expectNoHorizontalOverflow(page); }); +test( + "the 500px boundary preserves mounted panes, drafts, and keyboard controls", + async ({ join, seed }) => { + await seed(RESPONSIVE_SOURCE); + let page = await join("ana", { viewport: { width: 900, height: 850 } }); + let editor = content(page); + let chat = chatPane(page); + let draft = chat.locator("textarea"); + await draft.fill("An unfinished thought across layouts"); + await page.evaluate(() => { + let saved = window as typeof window & { + __workspaceEditor?: Element | null; + __workspaceChat?: Element | null; + }; + saved.__workspaceEditor = document.querySelector('[aria-label="editable markdown"]'); + saved.__workspaceChat = document.querySelector(".workspace-chat-panel"); + }); + + let fromCompactChat = false; + for (let width of [500, 499, 500]) { + await page.setViewportSize({ width, height: 850 }); + let split = width >= 500; + await expect(page.locator("[data-workspace-mode]")).toHaveAttribute( + "data-workspace-mode", + split ? "split" : "compact", + ); + if (split) { + await expect(chat).toBeVisible(); + await expect(page.getByRole("separator", { name: "Resize chat" })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0); + let views = page.getByRole("group", { name: "Document view" }); + await expect(views).toBeVisible(); + if (width === 500 && fromCompactChat) { + await page.getByRole("button", { name: "Hide chat pane" }).click(); + await expect(chat).toBeHidden(); + let showChat = page.getByRole("button", { name: "Show chat pane" }); + await expect(showChat).toBeFocused(); + await showChat.click(); + await expect(chat).toBeVisible(); + await expect(page.getByRole("heading", { name: "Chat", exact: true })).toBeFocused(); + fromCompactChat = false; + } + if (width === 500) { + let decisions = views.getByRole("button", { name: /^Decisions/ }); + let documentButton = views.getByRole("button", { name: "Document" }); + await documentButton.focus(); + await documentButton.press("Tab"); + await expect(decisions).toBeFocused(); + await expectInsideViewport(decisions); + let toolbarBounds = (await page.locator("[data-document-toolbar]").boundingBox())!; + let decisionBounds = (await decisions.boundingBox())!; + expect(decisionBounds.x).toBeGreaterThanOrEqual(toolbarBounds.x); + expect(decisionBounds.x + decisionBounds.width) + .toBeLessThanOrEqual(toolbarBounds.x + toolbarBounds.width); + await decisions.press("Enter"); + await expect(page.locator('[data-document-view="decisions"]')).toBeVisible(); + await documentButton.focus(); + await expectInsideViewport(documentButton); + await documentButton.press("Enter"); + await expect(editor).toBeEditable(); + } + } else { + let nav = page.getByRole("navigation", { name: "Workspace view" }); + await expect(nav).toBeVisible(); + await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0); + await nav.getByRole("button", { name: /^Chat/ }).click(); + await expect(chat).toBeVisible(); + await expect(draft).toHaveValue("An unfinished thought across layouts"); + await nav.getByRole("button", { name: "Document" }).click(); + await expect(editor).toBeEditable(); + if (width === 499) { + await nav.getByRole("button", { name: /^Chat/ }).click(); + fromCompactChat = true; + } + } + expect( + await page.evaluate(() => { + let saved = window as typeof window & { + __workspaceEditor?: Element | null; + __workspaceChat?: Element | null; + }; + return saved.__workspaceEditor + === document.querySelector('[aria-label="editable markdown"]') + && saved.__workspaceChat === document.querySelector(".workspace-chat-panel"); + }), + ).toBe(true); + await expect(draft).toHaveValue("An unfinished thought across layouts"); + await expectNoHorizontalOverflow(page); + } + + await page.setViewportSize({ width: 900, height: 850 }); + let handle = page.getByRole("separator", { name: "Resize chat" }); + let preferredWidth = Number(await handle.getAttribute("aria-valuemax")); + await handle.press("End"); + await expect.poll(async () => (await chat.boundingBox())!.width) + .toBeCloseTo(preferredWidth, 0); + await page.setViewportSize({ width: 500, height: 850 }); + let main = page.locator(".workspace-frame main"); + await expect.poll(async () => (await main.boundingBox())!.width).toBeGreaterThan(0); + let views = page.getByRole("group", { name: "Document view" }); + let documentButton = views.getByRole("button", { name: "Document" }); + let decisions = views.getByRole("button", { name: /^Decisions/ }); + await documentButton.focus(); + await documentButton.press("Tab"); + await expect(decisions).toBeFocused(); + await expectInsideViewport(decisions); + let toolbarBounds = (await page.locator("[data-document-toolbar]").boundingBox())!; + let decisionBounds = (await decisions.boundingBox())!; + expect(decisionBounds.x).toBeGreaterThanOrEqual(toolbarBounds.x); + expect(decisionBounds.x + decisionBounds.width) + .toBeLessThanOrEqual(toolbarBounds.x + toolbarBounds.width); + await page.setViewportSize({ width: 900, height: 850 }); + await expect.poll(async () => (await chat.boundingBox())!.width).toBeCloseTo(preferredWidth, 0); + await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) + .toBe(String(preferredWidth)); + }, +); + test("content swaps retain one interactive destination across pointer and immediate paths", async ({ join, seed }) => { await seed(RESPONSIVE_SOURCE); let page = await join("ana", { hasTouch: true, viewport: { width: 390, height: 844 } }); @@ -283,83 +401,82 @@ test("the safe area shell contains nonzero top and bottom insets on a 430px phon await expectNoHorizontalOverflow(page); }); -test("phone landscape header and navigation respect inline safe areas", async ({ join, page, seed }) => { +test("landscape split controls respect inline safe areas", async ({ join, page, seed }) => { await seed(RESPONSIVE_SOURCE); await page.setViewportSize({ width: 844, height: 390 }); let cdp = await page.context().newCDPSession(page); let safeArea = { bottom: 0, left: 32, right: 24, top: 0 }; await cdp.send("Emulation.setSafeAreaInsetsOverride", { insets: safeArea }); page = await join("ana"); - let nav = page.getByRole("navigation", { name: "Workspace view" }); + await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0); let header = page.getByRole("banner"); let headerControls = header.getByRole("button"); - let navControls = nav.getByRole("button"); - let [headerFirst, headerLast, navFirst, navLast] = await Promise.all([ + let chatToggle = page.getByRole("button", { name: "Hide chat pane" }); + let viewControls = page.getByRole("group", { name: "Document view" }).getByRole("button"); + let [headerFirst, headerLast, chatButton, viewFirst, viewLast] = await Promise.all([ headerControls.first().boundingBox(), headerControls.last().boundingBox(), - navControls.first().boundingBox(), - navControls.last().boundingBox(), + chatToggle.boundingBox(), + viewControls.first().boundingBox(), + viewControls.last().boundingBox(), ]); expect(headerFirst).toBeTruthy(); expect(headerLast).toBeTruthy(); - expect(navFirst).toBeTruthy(); - expect(navLast).toBeTruthy(); + expect(chatButton).toBeTruthy(); + expect(viewFirst).toBeTruthy(); + expect(viewLast).toBeTruthy(); let viewportWidth = await page.evaluate(() => visualViewport!.width); expect(headerFirst!.x).toBeGreaterThanOrEqual(safeArea.left); expect(headerLast!.x + headerLast!.width).toBeLessThanOrEqual( viewportWidth - safeArea.right, ); - expect(navFirst!.x).toBeGreaterThanOrEqual(safeArea.left); - expect(navLast!.x + navLast!.width).toBeLessThanOrEqual(viewportWidth - safeArea.right); - await expectInsideViewport(nav); + expect(chatButton!.x).toBeGreaterThanOrEqual(safeArea.left); + expect(viewFirst!.x).toBeGreaterThanOrEqual(safeArea.left); + expect(viewLast!.x + viewLast!.width).toBeLessThanOrEqual(viewportWidth - safeArea.right); + await expectInsideViewport(chatToggle); + await expectInsideViewport(viewControls.last()); await expectNoHorizontalOverflow(page); }); -test("a phone landscape still exposes one destination at a time", async ({ join, seed }) => { +test("an 844px landscape viewport keeps the split workspace", async ({ join, seed }) => { await seed(RESPONSIVE_SOURCE); let page = await join("ana", { viewport: { width: 844, height: 390 } }); - let nav = page.getByRole("navigation", { name: "Workspace view" }); - await nav.getByRole("button", { name: /Chat/ }).click(); + await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0); await expect(page.getByRole("complementary", { name: "Chat" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Chat" })).toHaveCount(0); - await expect(content(page)).toBeHidden(); - await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0); + await expect(content(page)).toBeEditable(); + await expect(page.getByRole("separator", { name: "Resize chat" })).toBeVisible(); await expectNoHorizontalOverflow(page); }); -test("the compact side of the Projects transition keeps phone navigation", async ({ join, seed }) => { +test("the Projects drawer below 1024px keeps the split workspace", async ({ join, seed }) => { let viewport = { width: 1023, height: 964 }; await seed(RESPONSIVE_SOURCE); let page = await join("ana", { viewport }); - let nav = page.getByRole("navigation", { name: "Workspace view" }); - let destinations = nav.getByRole("button"); - await expect(page.getByRole("button", { name: /chat pane/ })).toHaveCount(0); - await expect(page.getByRole("group", { name: "Document view" })).toHaveCount(0); - await expect(destinations.nth(0)).toHaveAccessibleName(/^Chat/); - await expect(destinations.nth(1)).toHaveAccessibleName("Document"); - await expect(destinations.nth(2)).toHaveAccessibleName(/^Decisions/); - - let opener = nav.getByRole("button", { name: /^Chat/ }); - await opener.click(); + await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0); + let projects = page.getByRole("button", { name: "Open Projects sidebar" }); + await expect(projects).toBeVisible(); + await projects.click(); + let drawer = page.getByRole("dialog", { name: "Projects" }); + await expect(drawer).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(drawer).toBeHidden(); + await expect(projects).toBeFocused(); + await expect(page.getByRole("group", { name: "Document view" })).toBeVisible(); let chat = page.getByRole("complementary", { name: "Chat" }); await expect(chat).toBeVisible(); await expect(page.getByRole("dialog", { name: "Chat" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Close chat" })).toHaveCount(0); - await expect(content(page)).toBeHidden(); - await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0); - await expect(page.getByRole("heading", { name: "Chat", exact: true })).toBeFocused(); - - await nav.getByRole("button", { name: "Document", exact: true }).click(); + await expect(content(page)).toBeEditable(); + await expect(page.getByRole("separator", { name: "Resize chat" })).toBeVisible(); + await page.getByRole("button", { name: "Hide chat pane" }).click(); await expect(chat).toBeHidden(); - await expect(content(page)).toBeVisible(); - await expect(page.getByRole("heading", { name: "Document", exact: true })).toBeFocused(); - + let opener = page.getByRole("button", { name: "Show chat pane" }); + await expect(opener).toBeFocused(); await opener.click(); await expect(chat).toBeVisible(); await expect(page.getByRole("heading", { name: "Chat", exact: true })).toBeFocused(); - await page.keyboard.press("Escape"); - await expect(chat).toBeHidden(); - await expect(opener).toBeFocused(); + await chat.locator("textarea").press("Escape"); + await expect(chat).toBeVisible(); await expectNoHorizontalOverflow(page); }); @@ -415,17 +532,20 @@ test("a representative desktop retains the split Chat layout", async ({ join, se await expect(content(page)).toBeEditable(); }); -test("200% zoom resolves to the compact presentation without clipping", async ({ join, seed }) => { +test("200% zoom at 640 CSS pixels keeps split controls without clipping", async ({ join, seed }) => { await seed(RESPONSIVE_SOURCE); let page = await join("ana", { screen: { width: 1280, height: 900 }, viewport: { width: 640, height: 450 }, }); - let nav = page.getByRole("navigation", { name: "Workspace view" }); - await expect(nav).toBeVisible(); - await nav.getByRole("button", { name: /Chat/ }).click(); + await expect(page.getByRole("navigation", { name: "Workspace view" })).toHaveCount(0); await expect(page.getByRole("complementary", { name: "Chat" })).toBeVisible(); - await expect(content(page)).toBeHidden(); + await expect(page.getByRole("group", { name: "Document view" })).toBeVisible(); + await expect(content(page)).toBeEditable(); + await expectNoHorizontalOverflow(page); + await page.setViewportSize({ width: 480, height: 450 }); + await expect(page.getByRole("navigation", { name: "Workspace view" })).toBeVisible(); + await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0); await expectNoHorizontalOverflow(page); }); diff --git a/e2e/shell.e2e.ts b/e2e/shell.e2e.ts index a14a63f46..b3e71cfeb 100644 --- a/e2e/shell.e2e.ts +++ b/e2e/shell.e2e.ts @@ -116,7 +116,7 @@ test("the chat rail edge follows the pointer", async ({ join, page }) => { }); test("the compact workspace keeps the document unobstructed", async ({ join, page }) => { - await page.setViewportSize({ width: 640, height: 800 }); + await page.setViewportSize({ width: 499, height: 800 }); await join("ana"); await expect(page.getByRole("separator", { name: "Resize chat" })).toHaveCount(0); diff --git a/e2e/sidecar.e2e.ts b/e2e/sidecar.e2e.ts index 2b6d091f6..248315f56 100644 --- a/e2e/sidecar.e2e.ts +++ b/e2e/sidecar.e2e.ts @@ -473,7 +473,7 @@ test("an edit received while compact Plan is hidden appears when it returns", as for ( let example of [ - { compact: true, name: "compact", width: 768 }, + { compact: true, name: "compact", width: 390 }, { compact: false, name: "desktop", width: 1280 }, ] ) { diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index 0c76ba414..dc00c4409 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -214,7 +214,7 @@ 1 ] ], - "sourceHash": "7b07be5a575fd36ebaac2f95b3f08258126e1efb2a2b3c48790502e63d382f7f" + "sourceHash": "8d81948c327fe4ff4a739f5ddbe7940c579b871d6618ce9379dcf77afb6c404e" }, { "file": "apps/web/src/workspace.tsx", @@ -249,7 +249,7 @@ 2 ] ], - "sourceHash": "7b07be5a575fd36ebaac2f95b3f08258126e1efb2a2b3c48790502e63d382f7f" + "sourceHash": "8d81948c327fe4ff4a739f5ddbe7940c579b871d6618ce9379dcf77afb6c404e" }, { "file": "apps/web/src/navigation-dialog.tsx",