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 ( - + ); } +/** 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. @@ -661,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 9aaa3e63..63a72939 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; @@ -241,23 +242,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 +294,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; @@ -324,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. */ @@ -498,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); @@ -598,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; @@ -741,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; @@ -768,7 +750,6 @@ } .trace .res-list { - position: relative; display: flex; flex-direction: column; } @@ -796,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; @@ -816,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; } @@ -1386,8 +1385,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; } } @@ -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); }