diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md
index f66e7d76..9d35c591 100644
--- a/packages/app-web/docs/architecture.md
+++ b/packages/app-web/docs/architecture.md
@@ -124,10 +124,15 @@ Text that is not UTF-8, or a binary file of a text type, gets the note.
| Left | The sidebar, in three sections that fold: File Explorer, Source Explorer, and Record Explorer, which lists two collections: the one picked (`?list=`) and the collection it is listed in. Collections takes at most 40% of Record Explorer and scrolls beyond that; Items has the rest and a filter box that narrows the listed items. |
| Centre | The graph, a card that folds: the resources opened in the workspace since its last reset and what they relate to, on a 3D canvas (`GraphView`). Clicking a node opens its resource. Below it, the open resource. Tabs: Overview (properties, then a card for each related resource, outbound then inbound), Debug, drawn as a wrench (the response; the events of the record, or of the acts that ran against the commit, each a line with chips for the records it names; and the commits those acts ran against). `?tab=` names the tab. The tabs and Reload sit at the right of the line with the resource's name. |
-The top bar starts with the LabKit mark and name, which are one button that opens the list of
-workspaces. The button after it shows or hides the sidebar. The theme button at the right has no
-border or background. The browser's own Back and Forward buttons step through the pages opened. The
-sidebar toggle works like this:
+The top bar holds the LabKit mark and name, which are one button that opens the list of workspaces,
+and the theme button, which has no border or background. The browser's own Back and Forward buttons
+step through the pages opened.
+
+The rail is a 44 px column at the left edge that stays when the sidebar is hidden. Its first button
+shows or hides the sidebar. Below it, one button for each section (File Explorer, Source Explorer,
+Record Explorer) folds the section when it is showing, and otherwise opens it and shows the sidebar.
+A button for a section that is unavailable is disabled, with the reason as its tooltip. The sidebar
+toggle works like this:
- At full width, the sidebar is shown or hidden in place, and the browser keeps the choice for the
next visit.
diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts
index 268104ad..aab24277 100644
--- a/packages/app-web/e2e/app.e2e.ts
+++ b/packages/app-web/e2e/app.e2e.ts
@@ -71,21 +71,19 @@ test.describe("getting around", () => {
await expect(page.getByText("Pick a type on the left")).toBeVisible();
});
- test("the top bar has the LabKit name first, then the sidebar toggle, and no history or filter controls", async ({
+ test("the top bar has the LabKit name and no sidebar, history or filter controls", async ({
page,
}) => {
await page.goto("/app/workspace/alpha/Q_1");
const bar = page.locator(".topbar");
- const brand = bar.getByRole("button", { name: "LabKit" });
- const toggle = bar.getByRole("button", { name: "Show or hide the sidebar" });
- const x = async (locator: import("@playwright/test").Locator) =>
- (await locator.boundingBox())?.x ?? -1;
- expect(await x(brand)).toBeLessThan(await x(toggle));
+ await expect(bar.getByRole("button", { name: "LabKit" })).toBeVisible();
+ await expect(bar.getByRole("button", { name: "Show or hide the sidebar" })).toHaveCount(0);
await expect(bar.getByRole("button", { name: "Back" })).toHaveCount(0);
await expect(bar.getByRole("button", { name: "Forward" })).toHaveCount(0);
await expect(bar.getByRole("button", { name: "Entry point" })).toHaveCount(0);
await expect(bar.getByRole("textbox")).toHaveCount(0);
await expect(bar).not.toContainText("Trace Console");
+ await expect(bar).not.toContainText("live");
});
test("the Items filter narrows the listed items", async ({ page }) => {
@@ -103,12 +101,14 @@ test.describe("getting around", () => {
await page.goto(`/app/workspace/alpha/Q_1?${list("/workspace/alpha/question")}`);
const panes = page.locator("aside.sidebar .explorer-body .sidebar-section");
await expect(panes).toHaveCount(2);
- const body = (await page.locator("aside.sidebar .explorer-body").boundingBox())?.height ?? 0;
- const collections = (await panes.nth(0).boundingBox())?.height ?? 0;
- const items = (await panes.nth(1).boundingBox())?.height ?? 0;
- expect(collections).toBeLessThanOrEqual(0.4 * body + 2);
- expect(items).toBeGreaterThanOrEqual(120);
- expect(items).toBeGreaterThan(collections);
+ const height = async (locator: import("@playwright/test").Locator) =>
+ (await locator.boundingBox())?.height ?? 0;
+ const body = page.locator("aside.sidebar .explorer-body");
+ await expect
+ .poll(async () => (await height(panes.nth(0))) <= 0.4 * (await height(body)) + 2)
+ .toBe(true);
+ expect(await height(panes.nth(1))).toBeGreaterThanOrEqual(120);
+ expect(await height(panes.nth(1))).toBeGreaterThan(await height(panes.nth(0)));
});
test("a deep link renders directly, and survives a reload", async ({ page }) => {
@@ -194,6 +194,31 @@ test.describe("the sidebar", () => {
const explorer = (page: import("@playwright/test").Page, name: string) =>
page.getByRole("button", { name, exact: true });
+ test("the rail stays when the sidebar is hidden; its section buttons fold or open sections and show the sidebar", async ({
+ page,
+ }) => {
+ await page.goto("/app/workspace/alpha/Q_1");
+ const rail = page.getByRole("navigation", { name: "Sidebar sections" });
+ await expect(rail).toBeVisible();
+ await toggle(page).click();
+ await expect(sidebar(page)).toBeHidden();
+ await expect(rail).toBeVisible();
+ const files = rail.getByRole("button", { name: "Open File Explorer" });
+ await files.click();
+ await expect(sidebar(page)).toBeVisible();
+ await expect(files).toHaveAttribute("aria-pressed", "true");
+ await expect(page.getByRole("region", { name: "File Explorer" })).toBeVisible();
+ await files.click();
+ await expect(files).toHaveAttribute("aria-pressed", "false");
+ await expect(
+ page.getByRole("region", { name: "File Explorer" }).locator(".explorer-body"),
+ ).toHaveCount(0);
+ // A folded section stays as tall as its header.
+ const folded = page.getByRole("region", { name: "File Explorer" });
+ await expect(folded.getByRole("button", { name: "File Explorer", exact: true })).toBeVisible();
+ await expect.poll(async () => (await folded.boundingBox())?.height ?? 0).toBeGreaterThan(30);
+ });
+
test("at full width, the toggle hides and shows the sidebar, and a reload keeps it hidden", async ({
page,
}) => {
@@ -336,6 +361,8 @@ test.describe("the sidebar", () => {
await expect
.poll(async () => (await top(box)) - (await top(commit("Add a picture and a guide"))))
.toBe(0);
+ // The box has rounded corners, as the boxes in the other lists do.
+ expect(await box.evaluate((e) => getComputedStyle(e).borderTopLeftRadius)).toBe("6px");
await commit("Side work").click();
await expect(commit("Side work")).toHaveClass(/active/);
await expect(history.locator(".active-box.fill")).toHaveClass(/filled/);
@@ -408,6 +435,8 @@ test.describe("the sidebar", () => {
await page.goto("/app/workspace/alpha/Q_1");
await explorer(page, "File Explorer").click();
await explorer(page, "Source Explorer").click();
+ // The sections' shares settle after their opening transition.
+ await expect(page.locator(".explorer.moving")).toHaveCount(0);
const section = (name: string) => sidebar(page).getByRole("region", { name });
const height = async (name: string) =>
Math.round((await section(name).boundingBox())?.height ?? -1);
@@ -450,6 +479,8 @@ test.describe("the sidebar", () => {
await page.goto("/app/workspace/alpha/Q_1");
await explorer(page, "File Explorer").click();
await explorer(page, "Source Explorer").click();
+ // The sections' shares settle after their opening transition.
+ await expect(page.locator(".explorer.moving")).toHaveCount(0);
const section = (name: string) => sidebar(page).getByRole("region", { name });
const height = async (name: string) =>
Math.round((await section(name).boundingBox())?.height ?? -1);
@@ -496,6 +527,8 @@ test.describe("the sidebar", () => {
await page.goto("/app/workspace/alpha/Q_1");
await explorer(page, "File Explorer").click();
await explorer(page, "Source Explorer").click();
+ // The sections' shares settle after their opening transition.
+ await expect(page.locator(".explorer.moving")).toHaveCount(0);
const section = (name: string) => sidebar(page).getByRole("region", { name });
const height = async (name: string) =>
Math.round((await section(name).boundingBox())?.height ?? -1);
diff --git a/packages/app-web/src/ui/trace/TraceConsole.tsx b/packages/app-web/src/ui/trace/TraceConsole.tsx
index f0b457f4..82f46493 100644
--- a/packages/app-web/src/ui/trace/TraceConsole.tsx
+++ b/packages/app-web/src/ui/trace/TraceConsole.tsx
@@ -41,6 +41,7 @@ import {
type RefObject,
useCallback,
useEffect,
+ useLayoutEffect,
useId,
useMemo,
useRef,
@@ -223,19 +224,6 @@ export function TraceConsole({ path, list, tab }: TraceConsoleProps) {
LabKit
-
{import.meta.env.DEV ? : null}
@@ -261,6 +249,13 @@ export function TraceConsole({ path, list, tab }: TraceConsoleProps) {
root={listKey === ENTRY}
openKey={path}
held={held}
+ expanded={narrow ? drawer === "items" : sidebarShown}
+ onToggle={() =>
+ narrow
+ ? setDrawer((d) => (d === "items" ? undefined : "items"))
+ : setSidebarShown(!sidebarShown)
+ }
+ onShow={() => (narrow ? setDrawer("items") : setSidebarShown(true))}
onOpen={open}
onList={pickList}
/>
@@ -402,6 +397,9 @@ function Lists({
root,
openKey,
held,
+ expanded,
+ onToggle,
+ onShow,
onOpen,
onList,
}: {
@@ -412,6 +410,11 @@ function Lists({
root: boolean;
openKey: string;
held: HeldIndex;
+ /** Whether the sidebar is shown, or in a narrow window, whether its drawer is open. */
+ expanded: boolean;
+ onToggle: () => void;
+ /** Shows the sidebar, or opens its drawer in a narrow window. */
+ onShow: () => void;
onOpen: (key: string) => void;
onList: (key: string) => void;
}) {
@@ -526,112 +529,177 @@ function Lists({
);
};
- return (
-