diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index b590df838..7d623e795 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 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 5cee97048..739d63b38 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, @@ -772,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 () => { @@ -812,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 6364048a0..ff67e890c 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -312,10 +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"); + 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, - immediately, + modeChanged || (mode !== "split" && immediately), ); let chatTrack = usePaneMotion(chatPresence.phase); let documentSwap = useRef(null); @@ -359,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 eb07219fa..a29bcdde4 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 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 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..e201a5ebd 100644 --- a/e2e/keyboard-shortcuts.e2e.ts +++ b/e2e/keyboard-shortcuts.e2e.ts @@ -117,6 +117,85 @@ 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("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; diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index e1fe8f449..f4ec8cd20 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": "fb9410656b9c10750c96c18da033f999266277037b6caabc276d69860478f44c" }, { "file": "apps/web/src/navigation-drawer.tsx", @@ -276,7 +276,7 @@ 1 ] ], - "sourceHash": "443502c52a0e791e696f3624e02ab96a4a85891cf3b3567ec42957dd1407de57" + "sourceHash": "39847923947ec187c3f3cb1973ab1df745dea4bcaff882fc5dbbf9bd2108d42f" }, { "file": "apps/web/src/navigation-dialog.tsx",