From 303e1c50866b98fe92ab3ca6f8c0bceb33143aa2 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Sun, 11 Oct 2026 23:34:45 +0100 Subject: [PATCH 1/3] A rail at the sidebar's left edge holds the sidebar toggle and one button per section The rail stays when the sidebar is hidden. The top bar keeps only the LabKit name and the theme button. Co-Authored-By: Claude Sonnet 5.5 --- packages/app-web/docs/architecture.md | 13 +- packages/app-web/e2e/app.e2e.ts | 31 +- .../app-web/src/ui/trace/TraceConsole.tsx | 284 +++++++++++------- packages/app-web/src/ui/trace/trace.css | 58 ++-- 4 files changed, 247 insertions(+), 139 deletions(-) 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..0c5925fc 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 }) => { @@ -194,6 +192,27 @@ 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); + }); + test("at full width, the toggle hides and shows the sidebar, and a reload keeps it hidden", async ({ page, }) => { diff --git a/packages/app-web/src/ui/trace/TraceConsole.tsx b/packages/app-web/src/ui/trace/TraceConsole.tsx index f0b457f4..66bb357b 100644 --- a/packages/app-web/src/ui/trace/TraceConsole.tsx +++ b/packages/app-web/src/ui/trace/TraceConsole.tsx @@ -223,19 +223,6 @@ export function TraceConsole({ path, list, tab }: TraceConsoleProps) { LabKit - {import.meta.env.DEV ? : null} @@ -261,6 +248,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 +396,9 @@ function Lists({ root, openKey, held, + expanded, + onToggle, + onShow, onOpen, onList, }: { @@ -412,6 +409,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,109 +528,171 @@ function Lists({ ); }; - return ( - + ); } diff --git a/packages/app-web/src/ui/trace/trace.css b/packages/app-web/src/ui/trace/trace.css index 9aaa3e63..35011c56 100644 --- a/packages/app-web/src/ui/trace/trace.css +++ b/packages/app-web/src/ui/trace/trace.css @@ -241,23 +241,6 @@ letter-spacing: 0.01em; } -.trace .hamburger { - display: flex; - background: none; - border: 0; - border-radius: 6px; - width: 30px; - height: 30px; - padding: 0; - align-items: center; - justify-content: center; - color: var(--text-dim); -} -.trace .hamburger:hover { - background: var(--panel-2); - color: var(--text); -} - .trace .topbar-gap { flex: 1 1 auto; } @@ -310,6 +293,43 @@ min-height: 0; } +/* The rail: a narrow column of icons at the left edge that stays when the sidebar is hidden. Its + first button shows or hides the sidebar; the others fold or open one section each. */ +.trace .rail { + flex: 0 0 44px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 0; + border-right: 1px solid var(--line); + background: var(--panel); +} +.trace .rail-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 6px; + background: none; + color: var(--text-dim); + font-size: 18px; +} +.trace .rail-btn:hover:not(:disabled) { + background: var(--panel-2); + color: var(--text); +} +.trace .rail-btn.on { + background: var(--accent-dim); + color: var(--text); +} +.trace .rail-btn:disabled { + color: var(--text-faint); + cursor: default; +} .trace .sidebar { flex: 0 0 288px; width: 288px; @@ -1403,10 +1423,10 @@ position: fixed; z-index: 20; top: 52px; - left: 0; + left: 44px; bottom: 0; width: 288px; - transform: translateX(-100%); + transform: translateX(calc(-100% - 44px)); transition: transform 0.18s ease; box-shadow: 2px 0 16px rgba(0, 0, 0, 0.35); } From c72f23e6f51d504e53c088a58da989d524fd4bd9 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Sun, 11 Oct 2026 23:56:33 +0100 Subject: [PATCH 2/3] Sidebar sections open and fold by transition, and the sidebar slides in and out A section's share of the height animates when the section opens or folds, and not while a border is dragged. A folding section keeps its body drawn until it reaches its header's height. The sidebar slides shut to nothing at full width, with its contents cut off rather than squeezed. Co-Authored-By: Claude Sonnet 5.5 --- packages/app-web/e2e/app.e2e.ts | 24 ++++-- .../app-web/src/ui/trace/TraceConsole.tsx | 80 ++++++++++++++----- packages/app-web/src/ui/trace/trace.css | 44 ++++++++-- 3 files changed, 116 insertions(+), 32 deletions(-) diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index 0c5925fc..850e5711 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -101,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 }) => { @@ -211,6 +213,10 @@ test.describe("the sidebar", () => { 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 ({ @@ -427,6 +433,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); @@ -469,6 +477,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); @@ -515,6 +525,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 66bb357b..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, @@ -696,6 +697,9 @@ function Lists({ ); } +/** How long a section takes to open or fold, in milliseconds: trace.css's `.explorer` transition. */ +const SECTION_MOTION_MS = 220; + /** * A section of the sidebar that folds under its header. While `unavailable` is set, the header is * disabled and says why, and the section stays folded. @@ -725,31 +729,67 @@ function Explorer({ }) { const shown = open && unavailable === undefined; const why = useId(); + // The body stays drawn while the section shrinks to its header, so it does not vanish first. + const [mounted, setMounted] = useState(shown); + // True while the section opens or folds, the only time its share changes by transition: a drag + // of the border must move it at once. + const [moving, setMoving] = useState(false); + const was = useRef(shown); + const top = useRef(null); + // The header's height, which the section keeps while it shrinks to nothing else. + const topHeight = useRef(0); + useEffect(() => { + const changed = was.current !== shown; + was.current = shown; + if (shown) setMounted(true); + if (!changed) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + setMounted(shown); + return; + } + setMoving(true); + const timer = setTimeout(() => { + setMoving(false); + if (!shown) setMounted(false); + }, SECTION_MOTION_MS); + return () => clearTimeout(timer); + }, [shown]); + useLayoutEffect(() => { + if (!mounted || shown) topHeight.current = top.current?.offsetHeight ?? topHeight.current; + }); + const closing = mounted && !shown; return (
- - {unavailable === undefined ? null : ( -
- {unavailable} -
- )} - {shown ? ( +
+ + {unavailable === undefined ? null : ( +
+ {unavailable} +
+ )} +
+ {mounted ? (
{children}
) : null}
diff --git a/packages/app-web/src/ui/trace/trace.css b/packages/app-web/src/ui/trace/trace.css index 35011c56..53932310 100644 --- a/packages/app-web/src/ui/trace/trace.css +++ b/packages/app-web/src/ui/trace/trace.css @@ -90,6 +90,7 @@ .trace .spinner, .trace .skeleton .bar, .trace .active-box, + .trace .explorer, .trace .sidebar { animation: none; transition: none; @@ -344,16 +345,26 @@ /* The sidebar's sections fold under their headers. Record Explorer's Items pane scrolls; File Explorer and Source Explorer scroll as a whole. */ .trace .explorer { - flex: none; + /* A folded section has no share, and its minimum height, which is its header's, keeps it + visible. An open one has a share, set inline, of the height the others leave. Both have a + basis of 0, so the share animates. */ + flex: 0 1 0; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; +} +.trace .explorer.moving { + transition: flex-grow 220ms ease; +} +/* An open section may be shorter than its content, which is cut off. A section that is folding + keeps its header's height, set inline. */ +.trace .explorer.open, +.trace .explorer.closing { min-height: 0; + overflow: hidden; } -/* Open sections share the height the folded ones leave, in proportion to their shares, whatever - their content. */ -.trace .explorer.open { - flex: 1 1 0; +.trace .explorer-top { + flex: none; } /* The border between two open sections, which a drag moves. It lies over the border and takes no height of its own, and shows the accent while it is hovered, dragged or focused. */ @@ -1406,8 +1417,29 @@ /* ---------- full width: the sidebar is shown or hidden ---------- */ @media (min-width: 861px) { + /* The sidebar slides shut to nothing and then leaves the tab order. Its contents keep their width, + so they are cut off at its edge rather than squeezed. */ + .trace .sidebar { + transition: + flex-basis 220ms ease, + width 220ms ease, + min-width 220ms ease, + visibility 0s; + } + .trace .sidebar > * { + min-width: 287px; + } .trace .sidebar.collapsed { - display: none; + flex-basis: 0; + width: 0; + min-width: 0; + border-right-width: 0; + visibility: hidden; + transition: + flex-basis 220ms ease, + width 220ms ease, + min-width 220ms ease, + visibility 0s 220ms; } } From 70fe0fefa524ab22d0c1d6485983b11085bda002 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 00:25:23 +0100 Subject: [PATCH 3/3] Source Explorer's box has rounded corners; one rule set styles the box in all four lists The rules that hand a list's open row over to the box were repeated for Collections, Items, File Explorer and Source Explorer. They are now one set. The timeline rows have the same radius as the other rows. Co-Authored-By: Claude Sonnet 5.5 --- packages/app-web/e2e/app.e2e.ts | 2 + packages/app-web/src/ui/trace/trace.css | 88 ++++++++----------------- 2 files changed, 30 insertions(+), 60 deletions(-) diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index 850e5711..aab24277 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -361,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/); diff --git a/packages/app-web/src/ui/trace/trace.css b/packages/app-web/src/ui/trace/trace.css index 53932310..63a72939 100644 --- a/packages/app-web/src/ui/trace/trace.css +++ b/packages/app-web/src/ui/trace/trace.css @@ -529,20 +529,6 @@ border-color: var(--accent); background: var(--accent-dim); } -/* In File Explorer the box marks the open file, as it does in the Items pane. */ -.trace .file-tree { - position: relative; -} -.trace .file-tree .tree-row { - position: relative; -} -.trace .file-tree .tree-row.active { - background: none; - border-color: transparent; -} -.trace .file-tree .active-box.fill:not(.filled) ~ .tree-row.active { - background: var(--panel-2); -} .trace .tree-icon { flex: none; color: var(--text-dim); @@ -629,31 +615,10 @@ gap: 6px; height: 38px; padding: 0 6px 0 2px; - border-radius: 0; } .trace .commit-row.timeline-row.tip { border-color: var(--accent); } -/* In Source Explorer the box marks the open commit, as it does in File Explorer. Rows touch, so - the box has square corners like them. An open tip keeps the tip's outline. */ -.trace .history { - position: relative; -} -.trace .history .commit-row { - position: relative; -} -.trace .history .commit-row.active { - background: none; -} -.trace .history .commit-row.active:not(.tip) { - border-color: transparent; -} -.trace .history .active-box.fill:not(.filled) ~ .commit-row.active { - background: var(--panel-2); -} -.trace .history .active-box { - border-radius: 0; -} .trace .lanes { flex: none; stroke-width: 1.5; @@ -772,20 +737,6 @@ background: var(--accent-dim); border-color: var(--accent); } -/* In the Collections pane the box marks the listed collection, as it marks the open row below. */ -.trace .type-list { - position: relative; -} -.trace .type-list .type-row { - position: relative; -} -.trace .type-list .type-row.active { - background: none; - border-color: transparent; -} -.trace .type-list .active-box.fill:not(.filled) ~ .type-row.active { - background: var(--panel-2); -} .trace .type-swatch { width: 9px; height: 9px; @@ -799,7 +750,6 @@ } .trace .res-list { - position: relative; display: flex; flex-direction: column; } @@ -827,6 +777,34 @@ z-index: 2; border: 1px solid var(--accent); } +/* Every list the box marks: Collections, Items, File Explorer and Source Explorer. The list holds + the box, which the rows are drawn over; the open row shows no fill or border of its own, except + its hover background until the box arrives, and the outline of an open branch tip. */ +.trace :is(.file-tree, .type-list, .res-list, .history) { + position: relative; +} +.trace :is(.file-tree .tree-row, .type-list .type-row, .res-list .res-item, .history .commit-row) { + position: relative; +} +.trace + :is( + .file-tree .tree-row, + .type-list .type-row, + .res-list .res-item, + .history .commit-row + ).active { + background: none; + border-color: transparent; +} +.trace .history .commit-row.active.tip { + border-color: var(--accent); +} +.trace + :is(.file-tree, .type-list, .res-list, .history) + .active-box.fill:not(.filled) + ~ :is(.tree-row, .type-row, .res-item, .commit-row).active { + background: var(--panel-2); +} .trace .res-item { display: flex; align-items: flex-start; @@ -847,16 +825,6 @@ background: var(--accent-dim); border-color: var(--accent); } -.trace .res-list .res-item { - position: relative; -} -.trace .res-list .res-item.active { - background: none; - border-color: transparent; -} -.trace .res-list .active-box.fill:not(.filled) ~ .res-item.active { - background: var(--panel-2); -} .trace .res-item .badge { margin-top: 1px; }