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));
},
);