Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 17 additions & 4 deletions apps/web/src/navigation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLElement>(".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 () => {
Expand Down Expand Up @@ -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 }));
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions e2e/keyboard-shortcuts.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions scripts/design-contract/exceptions/dynamic-web.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@
1
]
],
"sourceHash": "de727fb577f1efb11edfdc6aa89005d18b9eef7e445a6aabcffebf1f5b74aea3"
"sourceHash": "fb9410656b9c10750c96c18da033f999266277037b6caabc276d69860478f44c"
},
{
"file": "apps/web/src/navigation-drawer.tsx",
Expand Down Expand Up @@ -276,7 +276,7 @@
1
]
],
"sourceHash": "443502c52a0e791e696f3624e02ab96a4a85891cf3b3567ec42957dd1407de57"
"sourceHash": "39847923947ec187c3f3cb1973ab1df745dea4bcaff882fc5dbbf9bd2108d42f"
},
{
"file": "apps/web/src/navigation-dialog.tsx",
Expand Down
Loading