From ac64aa894cc49d91ae43e67a393af947cdffb542 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:16:55 +0100 Subject: [PATCH 1/3] Keep desktop workspace down to 500px --- apps/web/src/theme.css | 9 + apps/web/src/workspace-model.test.ts | 23 +- apps/web/src/workspace-model.ts | 2 +- apps/web/src/workspace.tsx | 10 + docs/jev-issues/JEV-008.md | 74 ++++++ docs/jev-issues/README.md | 19 ++ e2e/conversation-plan-ui.e2e.ts | 2 +- e2e/responsive-workspace.e2e.ts | 229 ++++++++++++++---- e2e/shell.e2e.ts | 2 +- e2e/sidecar.e2e.ts | 2 +- .../exceptions/dynamic-web.json | 4 +- 11 files changed, 317 insertions(+), 59 deletions(-) create mode 100644 docs/jev-issues/JEV-008.md create mode 100644 docs/jev-issues/README.md 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..340a7bbf9 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,18 @@ 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("uses the compact workspace only below 500px", () => { + expect([390, 499, 499.5, 500, 640, 900, 1023, 1024].map(width => workspaceMode(mediaAt(width)))) + .toEqual([ + "compact", + "compact", + "compact", + "split", + "split", + "split", + "split", + "split", + ]); }); it("closing Chat leaves the visible document view untouched", () => { @@ -137,7 +144,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..e403cf2c0 100644 --- a/e2e/responsive-workspace.e2e.ts +++ b/e2e/responsive-workspace.e2e.ts @@ -2,7 +2,7 @@ import { authenticate, content, expect, openIsolatedRoom, ready, test } from "./ import { expectInsideViewport, expectNoHorizontalOverflow, RESPONSIVE_SOURCE } from "./responsive"; import { installVisualViewport, setVisualViewport } from "./visual-viewport"; -import type { Browser, Page } from "@playwright/test"; +import type { Browser, Page, TestInfo } from "@playwright/test"; async function emulatedVisualViewportPage( browser: Browser, @@ -107,6 +107,143 @@ 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 }, testInfo: TestInfo) => { + 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 expect(page.getByRole("separator", { name: "Resize chat" })) + .toHaveAttribute("aria-valuenow", "304"); + await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) + .toBe("304"); + 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 [900, 640, 500, 499, 390, 500, 900]) { + 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 === 390) { + 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); + if ([900, 500, 390].includes(width)) { + await expect.poll(async () => { + let [chatBounds, frameBounds] = await Promise.all([ + chat.boundingBox(), + page.locator(".workspace-frame").boundingBox(), + ]); + return Math.abs(chatBounds!.x - frameBounds!.x); + }).toBeLessThan(1); + await page.screenshot({ path: testInfo.outputPath(`workspace-${width}.png`) }); + } + } + + let handle = page.getByRole("separator", { name: "Resize chat" }); + await handle.press("End"); + await expect(handle).toHaveAttribute("aria-valuenow", "400"); + await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) + .toBe("400"); + await page.setViewportSize({ width: 500, height: 850 }); + await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) + .toBe("400"); + let main = page.locator(".workspace-frame main"); + await expect.poll(async () => (await main.boundingBox())!.width).toBeGreaterThanOrEqual(144); + 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(400, 0); + await expect(handle).toHaveAttribute("aria-valuenow", "400"); + await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) + .toBe("400"); + await handle.press("Home"); + await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) + .toBe("304"); + }, +); + 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 +420,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 +551,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", From 8287984dba3f167504d1adf5adc8e5bc210048bb Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:17:48 +0100 Subject: [PATCH 2/3] Record breakpoint PR and verified visual preview --- docs/jev-issues/JEV-008.md | 9 +++++---- docs/jev-issues/README.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/jev-issues/JEV-008.md b/docs/jev-issues/JEV-008.md index 754326de7..980d26a09 100644 --- a/docs/jev-issues/JEV-008.md +++ b/docs/jev-issues/JEV-008.md @@ -1,6 +1,6 @@ # JEV-008 — Switch to the mobile workspace below 500px -Status: Verified; preparing PR. PR: Pending. Reported as “Issue 7”; stable ID: JEV-008. +Status: Ready for Maggie’s review. PR: [#246](https://github.com/githubnext/chopin/pull/246). Reported as “Issue 7”; stable ID: JEV-008. The current workspace is the visual reference. Maggie requested the desktop layout remain available in smaller windows and the mobile layout begin only below @@ -46,7 +46,7 @@ in a disposable fixture without copying its commits or merging either PR. 640 and 900px, resizing across the boundary, mounted identity, draft retention, document/decisions controls, and overflow. Inspect the wider-chat combination. - [x] Run formatting, types, CI and relevant unit/browser tests. Review the diff. -- [ ] Publish a separate fix PR with an inspected real UI image and verify its +- [x] Publish a separate fix PR with an inspected real UI image and verify its published inline image. Leave the live prototype/document and draft PR #241 unchanged; do not merge or push directly to the prototype. @@ -66,8 +66,9 @@ in a disposable fixture without copying its commits or merging either PR. two existing workspace source hashes were renewed; cases and reasons are unchanged. - Independent code review: approved, no actionable findings. -The real before/after 900px browser image was captured and visually inspected. -Final PR URL and published-image verification will be recorded after upload. +The real before/after 900px browser image was captured, visually inspected, and +published inline in PR #246. Its published image URL returned HTTP 200 with +`image/png`; no unresolved local image reference remains. An initial run of the unchanged desktop Decisions test had no injected question to focus. This did not reproduce: the original prototype and current fix each passed alone (1/1 each), and compact then desktop passed together (2/2). No source diff --git a/docs/jev-issues/README.md b/docs/jev-issues/README.md index 6b1c269a3..cc78c218c 100644 --- a/docs/jev-issues/README.md +++ b/docs/jev-issues/README.md @@ -12,7 +12,7 @@ remains draft. One issue per fix PR; no automatic merges or direct prototype pus | [JEV-005](https://github.com/githubnext/chopin/blob/maggie/jev-005-click-debug-popovers/docs/jev-issues/JEV-005.md) | Click-only Jev debug popovers with code-icon buttons | Ready for Maggie’s review | [#243](https://github.com/githubnext/chopin/pull/243) | | [JEV-006](https://github.com/githubnext/chopin/blob/maggie/jev-006-wider-chat/docs/jev-issues/JEV-006.md) | Default chat pane 60px wider | Ready for Maggie’s review | [#244](https://github.com/githubnext/chopin/pull/244) | | [JEV-007](https://github.com/githubnext/chopin/blob/maggie/jev-007-hide-chat-pills/docs/jev-issues/JEV-007.md) | Remove inline Jev chat pills | Ready for Maggie’s review | [#245](https://github.com/githubnext/chopin/pull/245) | -| [JEV-008](JEV-008.md) | Mobile workspace breakpoint triggers too early | Verified; preparing PR | Pending | +| [JEV-008](JEV-008.md) | Mobile workspace breakpoint triggers too early | Ready for Maggie’s review | [#246](https://github.com/githubnext/chopin/pull/246) | Evidence: [live browser pass](../jev-live-browser-test.md), [UI reconciliation](../jev-ui-reconciliation.md). Baseline: From 93bb0c25c48f679cc1b706106434c80e73b84ef1 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:47:23 +0100 Subject: [PATCH 3/3] Remove planning artifacts and streamline regression coverage --- apps/web/src/workspace-model.test.ts | 15 ++---- docs/jev-issues/JEV-008.md | 75 ---------------------------- docs/jev-issues/README.md | 19 ------- e2e/responsive-workspace.e2e.ts | 41 ++++----------- 4 files changed, 14 insertions(+), 136 deletions(-) delete mode 100644 docs/jev-issues/JEV-008.md delete mode 100644 docs/jev-issues/README.md diff --git a/apps/web/src/workspace-model.test.ts b/apps/web/src/workspace-model.test.ts index 340a7bbf9..9fb590cee 100644 --- a/apps/web/src/workspace-model.test.ts +++ b/apps/web/src/workspace-model.test.ts @@ -95,18 +95,9 @@ describe("adaptive workspace", () => { expect(workspaceHeadingId("plan", "child-room")).toBe("child-room-workspace-plan-heading"); }); - it("uses the compact workspace only below 500px", () => { - expect([390, 499, 499.5, 500, 640, 900, 1023, 1024].map(width => workspaceMode(mediaAt(width)))) - .toEqual([ - "compact", - "compact", - "compact", - "split", - "split", - "split", - "split", - "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", () => { diff --git a/docs/jev-issues/JEV-008.md b/docs/jev-issues/JEV-008.md deleted file mode 100644 index 980d26a09..000000000 --- a/docs/jev-issues/JEV-008.md +++ /dev/null @@ -1,75 +0,0 @@ -# JEV-008 — Switch to the mobile workspace below 500px - -Status: Ready for Maggie’s review. PR: [#246](https://github.com/githubnext/chopin/pull/246). Reported as “Issue 7”; stable ID: JEV-008. - -The current workspace is the visual reference. Maggie requested the desktop -layout remain available in smaller windows and the mobile layout begin only below -about 500px. Specs, plans, routine choices and testing are preapproved. - -## Design - -Use the existing workspace mode subscription with `(width < 500px)`: compact at -499px and narrower (including fractional widths below 500), split at 500px and -wider. Preserve mounted editor/chat state, drafts, pane preferences, and focus -behavior when resizing. Verify the existing owner callback clears compact Chat -state when hiding split Chat after a resize. Leave the independent Projects -sidebar drawer threshold at 1024px, where it provides room for the two workspace -panes. - -Changing both navigation and workspace to 500px would crowd three panes in a -small window. A new intermediate mode would add unnecessary state and controls. -The chosen approach keeps the current desktop and mobile layouts and moves only -their workspace boundary. Check actual pane geometry and controls near 500px; -include the phone landscape safe-area insets in the split frame margins. A real -844px-wide browser test with 32px left inset found the Chat control at x=22 before -that repair. Existing zero-inset desktop margins remain 12px. - -At 500px, fixture data with an unanswered Decision also exposed a focused -Decisions control extending to x=527 beyond the 500px viewport. Focus capture now -scrolls only the desktop toolbar horizontally to reveal a clipped control. This -keeps saved pane preferences and the editor scroll position. - -The combination with PR #244 and a saved 400px Chat width left only a 76px -document column at 500px, too narrow for its Decisions button. Give the split -document a 9rem minimum so flex sizing can shrink the rendered Chat pane when -necessary. Keep its stored width/min/max unchanged and restore that preferred -width as the window grows. Compact Chat remains full width. - -This fix starts at `origin/Maggie/jev-chat-product` and targets that prototype. -PR #244 separately changes the default chat width; verify the two fixes together -in a disposable fixture without copying its commits or merging either PR. - -## Small implementation plan - -- [x] Update `apps/web/src/workspace-model.ts` and its existing boundary test. -- [x] Update browser assertions that call 640px compact; exercise 390, 499, 500, - 640 and 900px, resizing across the boundary, mounted identity, draft retention, - document/decisions controls, and overflow. Inspect the wider-chat combination. -- [x] Run formatting, types, CI and relevant unit/browser tests. Review the diff. -- [x] Publish a separate fix PR with an inspected real UI image and verify its - published inline image. Leave the live prototype/document and draft PR #241 - unchanged; do not merge or push directly to the prototype. - -## Verification - -- Workspace, navigation, resize bounds/storage, and theme units: 59/59. -- Responsive browser suite: 18/18; shell: 11/11. Fixtures use the repository's - question/comment flags and shell uses plain flags. -- Final independent browser repeat: 3/3, covering the 500px boundary, landscape - safe areas, and the updated 390px Decisions case. Natural Tab keeps a focused - control inside the toolbar; rounded scroll deltas handle fractional pixels. -- Updated 390px chat-analysis Escape case: 1/1 using the local scripted Jev server. -- Disposable PR #244 combination: 1/1, covering default 364px and saved 400px - Chat sizes at 500px, full toolbar-control visibility, keyboard activation and - restored preferred width at 900px. Temporary overrides were restored exactly. -- `bun run fix`, `bun run types`, `bun run ci`, and diff checks pass. Only the - two existing workspace source hashes were renewed; cases and reasons are unchanged. -- Independent code review: approved, no actionable findings. - -The real before/after 900px browser image was captured, visually inspected, and -published inline in PR #246. Its published image URL returned HTTP 200 with -`image/png`; no unresolved local image reference remains. -An initial run of the unchanged desktop Decisions test had no injected question -to focus. This did not reproduce: the original prototype and current fix each -passed alone (1/1 each), and compact then desktop passed together (2/2). No source -change was made for that fixture result. Disposable test services were stopped. diff --git a/docs/jev-issues/README.md b/docs/jev-issues/README.md deleted file mode 100644 index cc78c218c..000000000 --- a/docs/jev-issues/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Jev issue queue - -Fix PRs target `Maggie/jev-chat-product`; [prototype PR #241](https://github.com/githubnext/chopin/pull/241) -remains draft. One issue per fix PR; no automatic merges or direct prototype pushes. - -| ID | Problem | Status | PR | -| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------- | -| [JEV-001](https://github.com/githubnext/chopin/blob/maggie/jev-001-decision-anchors/docs/jev-issues/JEV-001.md) | Incorrect decision anchors and overlapping markers | Ready for Maggie’s review | [#242](https://github.com/githubnext/chopin/pull/242) | -| [JEV-002](https://github.com/githubnext/chopin/blob/maggie/jev-001-decision-anchors/docs/jev-issues/JEV-002.md) | Stale rationale after changing a choice | Queued | Pending | -| [JEV-003](https://github.com/githubnext/chopin/blob/maggie/jev-001-decision-anchors/docs/jev-issues/JEV-003.md) | Useful questions/preferences unapplied; incomplete review recovery | Queued; determine separate recovery scopes before implementation | Pending | -| [JEV-004](https://github.com/githubnext/chopin/blob/maggie/jev-001-decision-anchors/docs/jev-issues/JEV-004.md) | Baseline CI conversation-capacity timeouts | Queued | Pending | -| [JEV-005](https://github.com/githubnext/chopin/blob/maggie/jev-005-click-debug-popovers/docs/jev-issues/JEV-005.md) | Click-only Jev debug popovers with code-icon buttons | Ready for Maggie’s review | [#243](https://github.com/githubnext/chopin/pull/243) | -| [JEV-006](https://github.com/githubnext/chopin/blob/maggie/jev-006-wider-chat/docs/jev-issues/JEV-006.md) | Default chat pane 60px wider | Ready for Maggie’s review | [#244](https://github.com/githubnext/chopin/pull/244) | -| [JEV-007](https://github.com/githubnext/chopin/blob/maggie/jev-007-hide-chat-pills/docs/jev-issues/JEV-007.md) | Remove inline Jev chat pills | Ready for Maggie’s review | [#245](https://github.com/githubnext/chopin/pull/245) | -| [JEV-008](JEV-008.md) | Mobile workspace breakpoint triggers too early | Ready for Maggie’s review | [#246](https://github.com/githubnext/chopin/pull/246) | - -Evidence: [live browser pass](../jev-live-browser-test.md), -[UI reconciliation](../jev-ui-reconciliation.md). Baseline: -`27eb3724a6e7ba3020552e7a1fa17f234811941b`. Notes first recorded 2 October 2026. diff --git a/e2e/responsive-workspace.e2e.ts b/e2e/responsive-workspace.e2e.ts index e403cf2c0..a5e8f41b3 100644 --- a/e2e/responsive-workspace.e2e.ts +++ b/e2e/responsive-workspace.e2e.ts @@ -2,7 +2,7 @@ import { authenticate, content, expect, openIsolatedRoom, ready, test } from "./ import { expectInsideViewport, expectNoHorizontalOverflow, RESPONSIVE_SOURCE } from "./responsive"; import { installVisualViewport, setVisualViewport } from "./visual-viewport"; -import type { Browser, Page, TestInfo } from "@playwright/test"; +import type { Browser, Page } from "@playwright/test"; async function emulatedVisualViewportPage( browser: Browser, @@ -109,16 +109,12 @@ test("a representative compact phone exposes one mounted destination at a time", test( "the 500px boundary preserves mounted panes, drafts, and keyboard controls", - async ({ join, seed }, testInfo: TestInfo) => { + 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 expect(page.getByRole("separator", { name: "Resize chat" })) - .toHaveAttribute("aria-valuenow", "304"); - await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) - .toBe("304"); await draft.fill("An unfinished thought across layouts"); await page.evaluate(() => { let saved = window as typeof window & { @@ -130,7 +126,7 @@ test( }); let fromCompactChat = false; - for (let width of [900, 640, 500, 499, 390, 500, 900]) { + 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( @@ -181,7 +177,7 @@ test( await expect(draft).toHaveValue("An unfinished thought across layouts"); await nav.getByRole("button", { name: "Document" }).click(); await expect(editor).toBeEditable(); - if (width === 390) { + if (width === 499) { await nav.getByRole("button", { name: /^Chat/ }).click(); fromCompactChat = true; } @@ -199,28 +195,17 @@ test( ).toBe(true); await expect(draft).toHaveValue("An unfinished thought across layouts"); await expectNoHorizontalOverflow(page); - if ([900, 500, 390].includes(width)) { - await expect.poll(async () => { - let [chatBounds, frameBounds] = await Promise.all([ - chat.boundingBox(), - page.locator(".workspace-frame").boundingBox(), - ]); - return Math.abs(chatBounds!.x - frameBounds!.x); - }).toBeLessThan(1); - await page.screenshot({ path: testInfo.outputPath(`workspace-${width}.png`) }); - } } + 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(handle).toHaveAttribute("aria-valuenow", "400"); - await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) - .toBe("400"); + await expect.poll(async () => (await chat.boundingBox())!.width) + .toBeCloseTo(preferredWidth, 0); await page.setViewportSize({ width: 500, height: 850 }); - await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) - .toBe("400"); let main = page.locator(".workspace-frame main"); - await expect.poll(async () => (await main.boundingBox())!.width).toBeGreaterThanOrEqual(144); + 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/ }); @@ -234,13 +219,9 @@ test( 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(400, 0); - await expect(handle).toHaveAttribute("aria-valuenow", "400"); - await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) - .toBe("400"); - await handle.press("Home"); + await expect.poll(async () => (await chat.boundingBox())!.width).toBeCloseTo(preferredWidth, 0); await expect.poll(() => page.evaluate(() => localStorage.getItem("chopin:pane:chat"))) - .toBe("304"); + .toBe(String(preferredWidth)); }, );