From 70240662d69f249dd1daa68abf31ce84fbcf20f9 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:09:53 +0100 Subject: [PATCH 1/4] Slide the sidebar and Chat for their keyboard shortcuts The sidebar and split Chat tracks settled at once whenever the last input was a key, so Cmd-\ and Cmd-. snapped while a click slid. Their presence now settles only on a layout change (sidebar) or reduced motion, matching pointer timing. Menus, dialogs, document views, compact panes and count feedback keep settling immediately for keyboard input. Co-Authored-By: Claude Opus 5.5 --- apps/web/DESIGN.md | 1 + apps/web/src/navigation-shell.tsx | 3 +- apps/web/src/workspace.tsx | 3 +- docs/architecture.md | 3 +- e2e/keyboard-shortcuts.e2e.ts | 58 +++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index b590df838..14689eb68 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -183,6 +183,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Do** use shared color, type, radius, spacing, shadow, and motion tokens when extending the interface. - **Do** use the established semantic color pairs for status and feedback. - **Do** keep the focus ring keyboard-only for buttons, menus, and the Chat composer (a click leaves it a quiet brand edge), and always visible on other text fields. Give a ring room to draw: clip only the axis that must clip. +- **Do** slide the Projects sidebar and split Chat the same way for their shortcuts as for a click, so the space a pane takes or gives back is always visible; reduced motion settles them at once. Other keyboard-driven changes (menus, dialogs, document views, compact panes, and count or icon feedback) still settle immediately. - **Do** check new controls in the development design audit alongside their default, focus, disabled, and error states. ### Don't: diff --git a/apps/web/src/navigation-shell.tsx b/apps/web/src/navigation-shell.tsx index 5cee97048..0b5b77145 100644 --- a/apps/web/src/navigation-shell.tsx +++ b/apps/web/src/navigation-shell.tsx @@ -290,10 +290,11 @@ export function NavigationShell( }, [mode]); let sidebarMotion = motionContract("sidebar"); let sidebarVisible = mode === "inline" && !collapsed; + // The sidebar slides for its shortcut as for a click; only a layout change settles it at once. let sidebarPresence = useTransitionPresence( sidebarVisible ? true : undefined, sidebarMotion.closeDuration, - immediateMotion || modeChanged, + modeChanged, ); let drawerPresence = useTransitionPresence( mode === "drawer" && drawerOpen && !phone ? true : undefined, diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index 6364048a0..76494e2a3 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -312,10 +312,11 @@ export function Workspace( // Phones crossfade rather than cut under reduced motion, so only keyboard input is immediate. let immediately = phone ? motionImmediately(undefined, false) : motionImmediately(); let contentSwapMotion = phone ? motionContract("tab-slide") : motionContract("content-swap"); + // Split Chat slides for its shortcut as for a click; compact panes still swap at once by keyboard. let chatPresence = useTransitionPresence( presentation.chatVisible ? true : undefined, phone ? contentSwapMotion.closeDuration : 220, - immediately, + mode === "split" ? false : immediately, ); let chatTrack = usePaneMotion(chatPresence.phase); let documentSwap = useRef(null); diff --git a/docs/architecture.md b/docs/architecture.md index eb07219fa..ea1b30b43 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -233,7 +233,8 @@ retention. The shared `ContentSwapLayer` owns presence timing: it makes an inactive layer `inert` and `aria-hidden` immediately, then hides it and notifies the host after its visual exit. Pointer input follows the host's transition contract; keyboard input and reduced-motion preferences settle the transition -immediately. +immediately. The Projects sidebar and split Chat tracks are not content swaps: +they slide for keyboard shortcuts too, and settle only under reduced motion. The editor's questionnaire host renders the current question and retains at most one outgoing question until that layer closes. Document navigation similarly diff --git a/e2e/keyboard-shortcuts.e2e.ts b/e2e/keyboard-shortcuts.e2e.ts index ed1f8e864..ce1c6deef 100644 --- a/e2e/keyboard-shortcuts.e2e.ts +++ b/e2e/keyboard-shortcuts.e2e.ts @@ -117,6 +117,64 @@ test("the chat chord toggles Chat without leaving the editor", async ({ join }) await expect(chatPane(page)).toBeVisible(); }); +/** Counts width transitions that start on the sidebar and Chat tracks. */ +async function recordPaneMotion(page: Page) { + await page.evaluate(() => { + let runs = { chat: 0, sidebar: 0 }; + (window as unknown as { paneRuns: typeof runs }).paneRuns = runs; + document.addEventListener("transitionrun", event => { + if (event.propertyName !== "width" || !(event.target instanceof Element)) return; + if (event.target.matches(".motion-sidebar")) runs.sidebar++; + if (event.target.matches(".workspace-chat-panel")) runs.chat++; + }); + }); + return () => + page.evaluate(() => + (window as unknown as { paneRuns: { chat: number; sidebar: number } }).paneRuns + ); +} + +test("the sidebar and Chat chords slide their panes as a click does", async ({ join, page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await join("ana"); + await expect(chatPane(page)).toBeVisible(); + await content(page).click(); + let runs = await recordPaneMotion(page); + + await pressShortcut(page, "Backslash"); + await expect(page.getByRole("button", { name: "Show sidebar" })).toBeVisible(); + await expect.poll(async () => (await runs()).sidebar).toBe(1); + await pressShortcut(page, "Backslash"); + await expect(page.getByRole("button", { name: "Hide sidebar" })).toBeVisible(); + await expect.poll(async () => (await runs()).sidebar).toBe(2); + + await pressShortcut(page, "Period"); + await expect(chatPane(page)).toBeHidden(); + await expect.poll(async () => (await runs()).chat).toBe(1); + await pressShortcut(page, "Period"); + await expect(chatPane(page)).toBeVisible(); + await expect.poll(async () => (await runs()).chat).toBe(2); +}); + +test("the sidebar and Chat chords settle at once under reduced motion", async ({ join, page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await join("ana"); + await expect(chatPane(page)).toBeVisible(); + await content(page).click(); + let runs = await recordPaneMotion(page); + + await pressShortcut(page, "Backslash"); + await expect(page.getByRole("button", { name: "Show sidebar" })).toBeVisible(); + await pressShortcut(page, "Backslash"); + await expect(page.getByRole("button", { name: "Hide sidebar" })).toBeVisible(); + await pressShortcut(page, "Period"); + await expect(chatPane(page)).toBeHidden(); + await pressShortcut(page, "Period"); + await expect(chatPane(page)).toBeVisible(); + expect(await runs()).toEqual({ chat: 0, sidebar: 0 }); +}); + test("Alt+N starts a new document only when nobody is typing", async ({ join }) => { let page = await join("ana"); let created = 0; From bef5b40d740d3882f8ab41ca37fec72858558b4f Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:10:00 +0100 Subject: [PATCH 2/4] Renew design-contract review for keyboard pane motion The workspace Chat panel and Projects sidebar class expressions are unchanged; only their presence hooks' settle arguments changed. Their classes still come from the finite presence states. Co-Authored-By: Claude Opus 5.5 --- scripts/design-contract/exceptions/dynamic-web.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index e1fe8f449..c2b8d1bdd 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -227,7 +227,7 @@ 1 ] ], - "sourceHash": "de727fb577f1efb11edfdc6aa89005d18b9eef7e445a6aabcffebf1f5b74aea3" + "sourceHash": "672d899bc30756af6681adc99dec1902afe855e3edf574181ea725372b9ed52d" }, { "file": "apps/web/src/navigation-drawer.tsx", @@ -276,7 +276,7 @@ 1 ] ], - "sourceHash": "443502c52a0e791e696f3624e02ab96a4a85891cf3b3567ec42957dd1407de57" + "sourceHash": "0c5e9827ddf9aad8c7d69e24d186d9bd3b1db0fca347159650bc414c0d1c0d40" }, { "file": "apps/web/src/navigation-dialog.tsx", From 5a13aebb17da0aa6fb19f60933247321b3df6d82 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:43:36 +0100 Subject: [PATCH 3/4] Settle Chat on layout changes and keep focus when the sidebar opens Widening from compact to split no longer slides Chat in. Opening the inline sidebar from Show sidebar moves focus to Hide sidebar instead of dropping it to the page. DESIGN.md now names the inline sidebar, so the drawer is clearly excluded. Co-Authored-By: Claude Opus 5.5 --- apps/web/DESIGN.md | 2 +- apps/web/src/navigation-shell.tsx | 18 +++++++++++++++--- apps/web/src/workspace.tsx | 8 +++++--- docs/architecture.md | 2 +- e2e/keyboard-shortcuts.e2e.ts | 21 +++++++++++++++++++++ 5 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 14689eb68..7d623e795 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -183,7 +183,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Do** use shared color, type, radius, spacing, shadow, and motion tokens when extending the interface. - **Do** use the established semantic color pairs for status and feedback. - **Do** keep the focus ring keyboard-only for buttons, menus, and the Chat composer (a click leaves it a quiet brand edge), and always visible on other text fields. Give a ring room to draw: clip only the axis that must clip. -- **Do** slide the Projects sidebar and split Chat the same way for their shortcuts as for a click, so the space a pane takes or gives back is always visible; reduced motion settles them at once. Other keyboard-driven changes (menus, dialogs, document views, compact panes, and count or icon feedback) still settle immediately. +- **Do** slide the inline Projects sidebar and split Chat the same way for their shortcuts as for a click, so the space a pane takes or gives back is always visible; reduced motion and a layout change settle them at once. Other keyboard-driven changes (the Projects drawer, menus, dialogs, document views, compact panes, and count or icon feedback) still settle immediately. - **Do** check new controls in the development design audit alongside their default, focus, disabled, and error states. ### Don't: diff --git a/apps/web/src/navigation-shell.tsx b/apps/web/src/navigation-shell.tsx index 0b5b77145..739d63b38 100644 --- a/apps/web/src/navigation-shell.tsx +++ b/apps/web/src/navigation-shell.tsx @@ -773,9 +773,21 @@ export function NavigationShell( workspaceDocumentAction, ]); + // The Show sidebar button leaves as the sidebar arrives, so focus on it moves to Hide sidebar. + let focusSidebar = useRef(false); + let showSidebar = useCallback(() => { + focusSidebar.current = !!drawerOpener.current?.contains(document.activeElement); + setCollapsed(false); + }, []); + useLayoutEffect(() => { + if (!sidebarVisible || !focusSidebar.current) return; + focusSidebar.current = false; + document.querySelector(".project-sidebar-frame [aria-label='Hide sidebar']") + ?.focus({ preventScroll: true }); + }, [sidebarVisible]); let expandSidebar = useCallback( - () => mode === "drawer" ? setDrawerOpen(true) : setCollapsed(false), - [mode], + () => mode === "drawer" ? setDrawerOpen(true) : showSidebar(), + [mode, showSidebar], ); let signOut = async () => { @@ -813,7 +825,7 @@ export function NavigationShell( shortcuts: () => showDialog("shortcuts"), "toggle-sidebar": () => { if (mode === "drawer") return setDrawerOpen(true); - if (collapsed) return setCollapsed(false); + if (collapsed) return showSidebar(); let inside = document.activeElement?.closest(".project-sidebar-frame"); setCollapsed(true); if (inside) requestAnimationFrame(() => drawerOpener.current?.focus({ preventScroll: true })); diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index 76494e2a3..ff67e890c 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -312,11 +312,14 @@ export function Workspace( // Phones crossfade rather than cut under reduced motion, so only keyboard input is immediate. let immediately = phone ? motionImmediately(undefined, false) : motionImmediately(); let contentSwapMotion = phone ? motionContract("tab-slide") : motionContract("content-swap"); - // Split Chat slides for its shortcut as for a click; compact panes still swap at once by keyboard. + let previousMode = useRef(mode); + let modeChanged = previousMode.current !== mode; + // Split Chat slides for its shortcut as for a click; compact panes still swap at once by keyboard, + // and a layout change settles Chat where it lands. let chatPresence = useTransitionPresence( presentation.chatVisible ? true : undefined, phone ? contentSwapMotion.closeDuration : 220, - mode === "split" ? false : immediately, + modeChanged || (mode !== "split" && immediately), ); let chatTrack = usePaneMotion(chatPresence.phase); let documentSwap = useRef(null); @@ -360,7 +363,6 @@ export function Workspace( previousChatOpen.current = state.chatOpen; }, [mode, state.chatOpen]); - let previousMode = useRef(mode); useLayoutEffect(() => { if (previousMode.current === mode) return; previousMode.current = mode; diff --git a/docs/architecture.md b/docs/architecture.md index ea1b30b43..a29bcdde4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -233,7 +233,7 @@ retention. The shared `ContentSwapLayer` owns presence timing: it makes an inactive layer `inert` and `aria-hidden` immediately, then hides it and notifies the host after its visual exit. Pointer input follows the host's transition contract; keyboard input and reduced-motion preferences settle the transition -immediately. The Projects sidebar and split Chat tracks are not content swaps: +immediately. The inline Projects sidebar and split Chat tracks are not content swaps: they slide for keyboard shortcuts too, and settle only under reduced motion. The editor's questionnaire host renders the current question and retains at most diff --git a/e2e/keyboard-shortcuts.e2e.ts b/e2e/keyboard-shortcuts.e2e.ts index ce1c6deef..e201a5ebd 100644 --- a/e2e/keyboard-shortcuts.e2e.ts +++ b/e2e/keyboard-shortcuts.e2e.ts @@ -175,6 +175,27 @@ test("the sidebar and Chat chords settle at once under reduced motion", async ({ expect(await runs()).toEqual({ chat: 0, sidebar: 0 }); }); +test("Enter on Show sidebar moves focus to Hide sidebar", async ({ join, page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await join("ana"); + await page.getByRole("button", { name: "Hide sidebar" }).click(); + let show = page.getByRole("button", { name: "Show sidebar" }); + await show.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("button", { name: "Hide sidebar" })).toBeFocused(); +}); + +test("widening into the split layout settles Chat without sliding it in", async ({ join, page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await join("ana"); + await expect(page.locator("[data-workspace-mode='compact']")).toHaveCount(1); + let runs = await recordPaneMotion(page); + await page.setViewportSize({ width: 1440, height: 900 }); + await expect(chatPane(page)).toBeVisible(); + await page.waitForTimeout(400); + expect((await runs()).chat).toBe(0); +}); + test("Alt+N starts a new document only when nobody is typing", async ({ join }) => { let page = await join("ana"); let created = 0; From 5040318b1dfb06fb44c6509bced3ee8d18187017 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:43:44 +0100 Subject: [PATCH 4/4] Renew design-contract review for Chat layout settle and sidebar focus Class expressions in workspace.tsx and navigation-shell.tsx are unchanged; the edits add a layout-change settle flag and a focus hand-off only. Co-Authored-By: Claude Opus 5.5 --- scripts/design-contract/exceptions/dynamic-web.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index c2b8d1bdd..f4ec8cd20 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -227,7 +227,7 @@ 1 ] ], - "sourceHash": "672d899bc30756af6681adc99dec1902afe855e3edf574181ea725372b9ed52d" + "sourceHash": "fb9410656b9c10750c96c18da033f999266277037b6caabc276d69860478f44c" }, { "file": "apps/web/src/navigation-drawer.tsx", @@ -276,7 +276,7 @@ 1 ] ], - "sourceHash": "0c5e9827ddf9aad8c7d69e24d186d9bd3b1db0fca347159650bc414c0d1c0d40" + "sourceHash": "39847923947ec187c3f3cb1973ab1df745dea4bcaff882fc5dbbf9bd2108d42f" }, { "file": "apps/web/src/navigation-dialog.tsx",