diff --git a/.lane/attic/packages/diagrams/src/diagram.tsx/01M4T3BMJ8379Q75HDMYS12M5Z-zoom-null-means-fit-to-stage.md b/.lane/attic/packages/diagrams/src/diagram.tsx/01M4T3BMJ8379Q75HDMYS12M5Z-zoom-null-means-fit-to-stage.md new file mode 100644 index 000000000..b4bea7cd4 --- /dev/null +++ b/.lane/attic/packages/diagrams/src/diagram.tsx/01M4T3BMJ8379Q75HDMYS12M5Z-zoom-null-means-fit-to-stage.md @@ -0,0 +1,8 @@ +--- +id: 01M4T3BMJ8379Q75HDMYS12M5Z +anchor: useState(null) +created: 2026-10-13T07:41:44Z +norm: '1' +--- + +Zoom null means fit-to-stage in CSS (min(100%, authored px)), so the first frame already fits and resizes track it; a number is a user choice that survives resize. Spec changes remount (keyed by fingerprint) and return to fit. diff --git a/.lane/attic/packages/editor/src/styles.css/01M4T3BMKG5MRBYK5WEGD0MZPW-investigation-evidence-table.md b/.lane/attic/packages/editor/src/styles.css/01M4T3BMKG5MRBYK5WEGD0MZPW-investigation-evidence-table.md new file mode 100644 index 000000000..cbab14a39 --- /dev/null +++ b/.lane/attic/packages/editor/src/styles.css/01M4T3BMKG5MRBYK5WEGD0MZPW-investigation-evidence-table.md @@ -0,0 +1,8 @@ +--- +id: 01M4T3BMKG5MRBYK5WEGD0MZPW +anchor: .plan-content .plan-evidence table +created: 2026-10-13T07:41:44Z +norm: '1' +--- + +Investigation evidence tables sit inside .plan-content, so they inherit the authored-table display:block and 7rem cell floor unless reset here; that floor pushed the 4th column off a phone. diff --git a/.lane/memory/apps/server/src/questions/anchors.ts/01M4T8QPPHVX0JGCQT2YE672VG-an-open-question-s-empty-anc.md b/.lane/memory/apps/server/src/questions/anchors.ts/01M4T8QPPHVX0JGCQT2YE672VG-an-open-question-s-empty-anc.md index 03b033a1d..c49605241 100644 --- a/.lane/memory/apps/server/src/questions/anchors.ts/01M4T8QPPHVX0JGCQT2YE672VG-an-open-question-s-empty-anc.md +++ b/.lane/memory/apps/server/src/questions/anchors.ts/01M4T8QPPHVX0JGCQT2YE672VG-an-open-question-s-empty-anc.md @@ -3,6 +3,10 @@ id: 01M4T8QPPHVX0JGCQT2YE672VG anchor: function answer created: 2026-10-13T09:15:43Z norm: '1' +sig: 18ca2a2469c3ff47 +body_hash: 0c130b62b8c9c7d7 +raw_hash: 93e9001c5aa26725 +lines: 83-89 --- An open question's empty anchor set gets persisted by Questions.rebase as {anchors:[], pending:false}, indistinguishable from a reviewed-empty set. Answering must convert empty sets to pending/missing (Anchors.answer) or a blank-document ask reads as 'No related text' forever. diff --git a/.lane/memory/e2e/decision-prose.e2e.ts/01M4TE639KWKCMD26E81A3NV5C-a-blank-document-automatical.md b/.lane/memory/e2e/decision-prose.e2e.ts/01M4TE639KWKCMD26E81A3NV5C-a-blank-document-automatical.md index 2fcef78a8..bc310f2d4 100644 --- a/.lane/memory/e2e/decision-prose.e2e.ts/01M4TE639KWKCMD26E81A3NV5C-a-blank-document-automatical.md +++ b/.lane/memory/e2e/decision-prose.e2e.ts/01M4TE639KWKCMD26E81A3NV5C-a-blank-document-automatical.md @@ -3,6 +3,10 @@ id: 01M4TE639KWKCMD26E81A3NV5C anchor: function decide created: 2026-10-13T10:50:57Z norm: '1' +sig: 8507735bfb3c3993 +body_hash: 1491cac8014b219f +raw_hash: a3ef7a4a36fbfca5 +lines: 61-75 --- A blank document automatically opens new questions in Decisions. These prose tests explicitly return to Document before answering so their inline card, marker, and collapse assertions exercise the document rather than the hidden pane. diff --git a/.lane/memory/packages/diagrams/src/diagram.tsx/01M4TJYVPVDXRJQRMYATXF9R1R-zoom-null-means-fit-to-stage.md b/.lane/memory/packages/diagrams/src/diagram.tsx/01M4TJYVPVDXRJQRMYATXF9R1R-zoom-null-means-fit-to-stage.md new file mode 100644 index 000000000..767b52f70 --- /dev/null +++ b/.lane/memory/packages/diagrams/src/diagram.tsx/01M4TJYVPVDXRJQRMYATXF9R1R-zoom-null-means-fit-to-stage.md @@ -0,0 +1,13 @@ +--- +id: 01M4TJYVPVDXRJQRMYATXF9R1R +anchor: function DiagramView +created: 2026-10-13T12:14:23Z +norm: '1' +sig: 7681c7b78f8383bc +body_hash: 43ceeed9d2f21cfc +raw_hash: 9834f5214d3d1a8e +lines: 83-507 +supersedes: 01M4T3BMJ8379Q75HDMYS12M5Z +--- + +Zoom null means fit-to-stage in CSS (min(100%, authored px)), so the first frame already fits and resizes track it; a number is a user choice that survives resize. Spec changes remount (keyed by fingerprint) and return to fit. diff --git a/.lane/memory/packages/editor/src/deck-leave.ts/01M4TGSVAG4K3CADXT761BB7SY-hidden-accepted-comment-deci.md b/.lane/memory/packages/editor/src/deck-leave.ts/01M4TGSVAG4K3CADXT761BB7SY-hidden-accepted-comment-deci.md index 2ac721499..037bb4881 100644 --- a/.lane/memory/packages/editor/src/deck-leave.ts/01M4TGSVAG4K3CADXT761BB7SY-hidden-accepted-comment-deci.md +++ b/.lane/memory/packages/editor/src/deck-leave.ts/01M4TGSVAG4K3CADXT761BB7SY-hidden-accepted-comment-deci.md @@ -3,6 +3,10 @@ id: 01M4TGSVAG4K3CADXT761BB7SY anchor: function $caretAfterRun created: 2026-10-13T11:36:42Z norm: '1' +sig: 3b527488d113a979 +body_hash: 9eaec0e0612244a8 +raw_hash: c35fb166b171e126 +lines: 17-27 --- Hidden accepted-comment Decision projections are decorator nodes and can follow a questionnaire run. Continue to the next editable element when handing off the caret; stopping at a hidden decorator strands focus on the page. diff --git a/.lane/memory/packages/editor/src/styles.css/01M4TJYVQKV9KEAZV30B3PSVEM-investigation-evidence-table.md b/.lane/memory/packages/editor/src/styles.css/01M4TJYVQKV9KEAZV30B3PSVEM-investigation-evidence-table.md new file mode 100644 index 000000000..8d3f47b55 --- /dev/null +++ b/.lane/memory/packages/editor/src/styles.css/01M4TJYVQKV9KEAZV30B3PSVEM-investigation-evidence-table.md @@ -0,0 +1,13 @@ +--- +id: 01M4TJYVQKV9KEAZV30B3PSVEM +anchor: '@file' +created: 2026-10-13T12:14:23Z +norm: '1' +sig: e3b0c44298fc1c14 +body_hash: 6b17cdb9d33cec85 +raw_hash: f2e3cb960902c3f8 +lines: 1-5839 +supersedes: 01M4T3BMKG5MRBYK5WEGD0MZPW +--- + +Investigation evidence tables sit inside .plan-content, so they inherit the authored-table display:block and 7rem cell floor unless reset here; that floor pushed the 4th column off a phone. diff --git a/e2e/diagram-gallery/gallery.e2e.ts b/e2e/diagram-gallery/gallery.e2e.ts index 64a69619c..b1d661e25 100644 --- a/e2e/diagram-gallery/gallery.e2e.ts +++ b/e2e/diagram-gallery/gallery.e2e.ts @@ -90,7 +90,11 @@ test("gallery has no page-level horizontal overflow", async ({ page }) => { test("a narrow diagram exposes keyboard horizontal scrolling", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "narrow", "The document-width view fits at wide widths."); - let stage = page.locator('[data-specimen-diagram="first"] .ch-diagram__stage'); + let first = page.locator('[data-specimen-diagram="first"]'); + let stage = first.locator(".ch-diagram__stage"); + // A wide diagram opens fitted; at actual size it overflows and scrolls. + await expect(stage).not.toHaveAttribute("tabindex", "0"); + await first.getByRole("button", { name: "Actual size", exact: true }).click(); await expect(stage).toHaveAttribute("tabindex", "0"); await expect(stage).toHaveAttribute("aria-label", /scroll horizontally/i); await stage.focus(); @@ -99,96 +103,136 @@ test("a narrow diagram exposes keyboard horizontal scrolling", async ({ page }, await expect.poll(() => stage.evaluate(element => element.scrollLeft)).toBeGreaterThan(0); }); -test("reduced motion opens on the whole diagram and plays only when asked", async ({ page }) => { - await page.emulateMedia({ reducedMotion: "reduce" }); - await reload(page); - let first = page.locator('[data-specimen-diagram="first"]'); - let svg = first.locator("svg.sc-svg"); - await expect(svg).toBeVisible(); - let total = Number(await svg.getAttribute("data-sc-steps")); - let controls = first.getByRole("group", { name: "Diagram controls", exact: true }); - let counter = (step: number) => controls.getByText(`${step} / ${total}`, { exact: true }); - await expect(counter(total)).toBeVisible(); - await page.waitForTimeout(1000); - await expect(counter(total)).toBeVisible(); - await expect(controls.getByRole("button", { name: "Next step", exact: true })).toBeDisabled(); - - await controls.getByRole("button", { name: "Play diagram", exact: true }).click(); - await expect(counter(1)).toBeVisible(); - await expect(svg).toHaveClass(/sc-stepping/); - await expect(controls.getByRole("button", { name: "Pause diagram", exact: true })).toBeVisible(); - let shown = controls.locator(".ch-diagram__step"); - // Each step shows for one stagger; poll faster than expect's backoff. - await expect.poll(async () => Number((await shown.textContent())!.split(" / ")[0]), { - intervals: [50], - }).toBeGreaterThan(1); - await controls.getByRole("button", { name: "Restart diagram", exact: true }).click(); - await expect(counter(1)).toBeVisible(); - await expect(first.locator(".ch-diagram__status")).toBeEmpty(); -}); +test( + "reduced motion opens on the whole diagram and plays only when asked", + async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "wide", "A narrow bar has no step counter or Restart."); + await page.emulateMedia({ reducedMotion: "reduce" }); + await reload(page); + let first = page.locator('[data-specimen-diagram="first"]'); + let svg = first.locator("svg.sc-svg"); + await expect(svg).toBeVisible(); + let total = Number(await svg.getAttribute("data-sc-steps")); + let controls = first.getByRole("group", { name: "Diagram controls", exact: true }); + let counter = (step: number) => controls.getByText(`${step} / ${total}`, { exact: true }); + await expect(counter(total)).toBeVisible(); + await page.waitForTimeout(1000); + await expect(counter(total)).toBeVisible(); + await expect(controls.getByRole("button", { name: "Next step", exact: true })).toBeDisabled(); -test("diagram controls sit centred below the drawing and step to either end", async ({ page }) => { - let first = page.locator('[data-specimen-diagram="first"]'); - let stage = first.locator(".ch-diagram__stage"); - let controls = first.getByRole("group", { name: "Diagram controls", exact: true }); - await expect(controls).toBeVisible(); - let [stageBox, controlsBox] = [await stage.boundingBox(), await controls.boundingBox()]; - expect(controlsBox!.y).toBeGreaterThanOrEqual(stageBox!.y + stageBox!.height); - expect( - Math.abs( - controlsBox!.x + controlsBox!.width / 2 - (stageBox!.x + stageBox!.width / 2), - ), - ).toBeLessThanOrEqual(1); - - let toggle = (name: "Play diagram" | "Pause diagram") => - controls.getByRole("button", { name, exact: true }); - let previous = controls.getByRole("button", { name: "Previous step", exact: true }); - let next = controls.getByRole("button", { name: "Next step", exact: true }); - let restart = controls.getByRole("button", { name: "Restart diagram", exact: true }); - let shown = controls.locator(".ch-diagram__step"); - let total = Number(await first.locator("svg.sc-svg").getAttribute("data-sc-steps")); - expect(total).toBeGreaterThan(3); - let counter = (step: number) => controls.getByText(`${step} / ${total}`, { exact: true }); - let at = async () => Number((await shown.textContent())!.split(" / ")[0]); - - // Playing counts the step being played, and the arrows wait for a pause. - await restart.click(); - await expect(counter(1)).toBeVisible(); - await expect(previous).toBeDisabled(); - await expect(next).toBeDisabled(); - await expect.poll(at, { intervals: [50] }).toBeGreaterThan(1); - await toggle("Pause diagram").click(); - await expect(toggle("Play diagram")).toBeFocused(); - let paused = await at(); - expect(paused).toBeLessThan(total); - await page.waitForTimeout(1000); - await expect(counter(paused)).toBeVisible(); - - await next.click(); - await expect(counter(paused + 1)).toBeVisible(); - for (let step = paused; step >= 1; step--) { - await previous.click(); - await expect(counter(step)).toBeVisible(); - } - // A disabled arrow keeps focus, so the keyboard never drops out of the bar. - await expect(previous).toBeDisabled(); - await expect(previous).toBeFocused(); - - // Play resumes from the step on screen and stops at the last one. - await toggle("Play diagram").click(); - await expect(toggle("Pause diagram")).toBeVisible(); - await expect.poll(at, { intervals: [50] }).toBeGreaterThan(1); - await expect(counter(total)).toBeVisible({ timeout: 10_000 }); - await expect(toggle("Play diagram")).toBeVisible(); - await expect(next).toBeDisabled(); - await expect(previous).toBeEnabled(); - - await restart.click(); - await expect(counter(1)).toBeVisible(); - await expect(toggle("Pause diagram")).toBeVisible(); - await expect(controls.getByRole("button", { name: "Replay diagram" })).toHaveCount(0); - await expect(controls.getByRole("button", { name: "Reset diagram" })).toHaveCount(0); -}); + await controls.getByRole("button", { name: "Play diagram", exact: true }).click(); + await expect(counter(1)).toBeVisible(); + await expect(svg).toHaveClass(/sc-stepping/); + await expect(controls.getByRole("button", { name: "Pause diagram", exact: true })) + .toBeVisible(); + let shown = controls.locator(".ch-diagram__step"); + // Each step shows for one stagger; poll faster than expect's backoff. + await expect.poll(async () => Number((await shown.textContent())!.split(" / ")[0]), { + intervals: [50], + }).toBeGreaterThan(1); + await controls.getByRole("button", { name: "Restart diagram", exact: true }).click(); + await expect(counter(1)).toBeVisible(); + await expect(first.locator(".ch-diagram__status")).toBeEmpty(); + }, +); + +test( + "diagram controls sit centred below the drawing and step to either end", + async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "wide", "A narrow bar has no step counter or Restart."); + let first = page.locator('[data-specimen-diagram="first"]'); + let stage = first.locator(".ch-diagram__stage"); + let controls = first.getByRole("group", { name: "Diagram controls", exact: true }); + await expect(controls).toBeVisible(); + let [stageBox, controlsBox] = [await stage.boundingBox(), await controls.boundingBox()]; + expect(controlsBox!.y).toBeGreaterThanOrEqual(stageBox!.y + stageBox!.height); + expect( + Math.abs( + controlsBox!.x + controlsBox!.width / 2 - (stageBox!.x + stageBox!.width / 2), + ), + ).toBeLessThanOrEqual(1); + + let toggle = (name: "Play diagram" | "Pause diagram") => + controls.getByRole("button", { name, exact: true }); + let previous = controls.getByRole("button", { name: "Previous step", exact: true }); + let next = controls.getByRole("button", { name: "Next step", exact: true }); + let restart = controls.getByRole("button", { name: "Restart diagram", exact: true }); + let shown = controls.locator(".ch-diagram__step"); + let total = Number(await first.locator("svg.sc-svg").getAttribute("data-sc-steps")); + expect(total).toBeGreaterThan(3); + let counter = (step: number) => controls.getByText(`${step} / ${total}`, { exact: true }); + let at = async () => Number((await shown.textContent())!.split(" / ")[0]); + + // Playing counts the step being played, and the arrows wait for a pause. + await restart.click(); + await expect(counter(1)).toBeVisible(); + await expect(previous).toBeDisabled(); + await expect(next).toBeDisabled(); + await expect.poll(at, { intervals: [50] }).toBeGreaterThan(1); + await toggle("Pause diagram").click(); + await expect(toggle("Play diagram")).toBeFocused(); + let paused = await at(); + expect(paused).toBeLessThan(total); + await page.waitForTimeout(1000); + await expect(counter(paused)).toBeVisible(); + + await next.click(); + await expect(counter(paused + 1)).toBeVisible(); + for (let step = paused; step >= 1; step--) { + await previous.click(); + await expect(counter(step)).toBeVisible(); + } + // A disabled arrow keeps focus, so the keyboard never drops out of the bar. + await expect(previous).toBeDisabled(); + await expect(previous).toBeFocused(); + + // Play resumes from the step on screen and stops at the last one. + await toggle("Play diagram").click(); + await expect(toggle("Pause diagram")).toBeVisible(); + await expect.poll(at, { intervals: [50] }).toBeGreaterThan(1); + await expect(counter(total)).toBeVisible({ timeout: 10_000 }); + await expect(toggle("Play diagram")).toBeVisible(); + await expect(next).toBeDisabled(); + await expect(previous).toBeEnabled(); + + await restart.click(); + await expect(counter(1)).toBeVisible(); + await expect(toggle("Pause diagram")).toBeVisible(); + await expect(controls.getByRole("button", { name: "Replay diagram" })).toHaveCount(0); + await expect(controls.getByRole("button", { name: "Reset diagram" })).toHaveCount(0); + }, +); + +test( + "a narrow bar keeps one row, and Play restarts a finished diagram", + async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "narrow", "The whole bar fits at wide widths."); + await page.emulateMedia({ reducedMotion: "reduce" }); + await reload(page); + let first = page.locator('[data-specimen-diagram="first"]'); + let svg = first.locator("svg.sc-svg"); + await expect(svg).toBeVisible(); + let total = Number(await svg.getAttribute("data-sc-steps")); + let controls = first.getByRole("group", { name: "Diagram controls", exact: true }); + let status = first.locator(".ch-diagram__status"); + await expect(controls.getByRole("button", { name: "Restart diagram", exact: true })) + .toBeHidden(); + await expect(controls.locator(".ch-diagram__step")).toBeHidden(); + let tops = await controls.locator("button:visible").evaluateAll(buttons => + new Set(buttons.map(button => Math.round(button.getBoundingClientRect().top))).size + ); + expect(tops).toBe(1); + // Reduced motion opens finished, on the last step. + await expect(status).toHaveText(`Step ${total} of ${total}`); + await controls.getByRole("button", { name: "Play diagram", exact: true }).click(); + // Playing again starts over: the last step waits for its turn. + await expect(controls.getByRole("button", { name: "Pause diagram", exact: true })) + .toBeVisible(); + await expect(svg.locator(`[data-sc-step="${total}"][aria-hidden="true"]`).first()) + .toBeAttached(); + await expect(status).toHaveText(`Step ${total} of ${total}`, { timeout: 10_000 }); + }, +); test("a wireframe fence is drawn chromeless, and an invalid one stays code", async ({ page }) => { let blocks = page.locator("[data-wireframe-document] .planCode"); diff --git a/e2e/seecode.e2e.ts b/e2e/seecode.e2e.ts index a0ead1563..a80a1d7ac 100644 --- a/e2e/seecode.e2e.ts +++ b/e2e/seecode.e2e.ts @@ -34,7 +34,7 @@ let astro = { }; test( - "a wide diagram derives a readable narrower view with bounded zoom and unchanged source", + "a wide diagram derives a narrower view, fits its column and keeps a chosen zoom", async ({ join, room, seed }, testInfo) => { let source = `# Astro runtime\n\nThe build and release stages have different dependencies.\n\n\`\`\`seecode\n${ @@ -83,47 +83,53 @@ test( }; }); expect(geometry.viewBoxWidth).toBeLessThanOrEqual(1100); - expect(geometry.scale).toBeGreaterThanOrEqual(1); - if (geometry.viewBoxWidth <= geometry.stageWidth) { - expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.stageWidth + 1); - } else { - expect(geometry.scrollWidth).toBeGreaterThan(geometry.stageWidth); - } + // Fitted to the column when wider than it; otherwise drawn at its actual size. + expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.stageWidth + 1); + if (geometry.viewBoxWidth <= geometry.stageWidth) expect(geometry.scale).toBeCloseTo(1, 2); + else expect(geometry.scale).toBeLessThan(1); expect(geometry.pageScrollWidth).toBeLessThanOrEqual(geometry.pageWidth + 1); expect(geometry.documentScrollWidth).toBeLessThanOrEqual(geometry.documentWidth + 1); expect(geometry.stageLeft).toBeGreaterThanOrEqual(geometry.documentLeft - 1); expect(geometry.stageRight).toBeLessThanOrEqual(geometry.documentRight + 1); - if (geometry.scrollWidth <= geometry.stageWidth + 1) continue; - await expect(stage).toHaveAttribute("tabindex", "0"); - await stage.focus(); - await stage.evaluate(element => { - element.setAttribute("data-keyboard-scroll-complete", "false"); - element.addEventListener("scrollend", () => { - element.setAttribute("data-keyboard-scroll-complete", "true"); - }, { once: true }); - }); - await stage.press("ArrowRight"); - await expect.poll(() => stage.evaluate(element => element.scrollLeft)).toBeGreaterThan(0); - await expect(stage).toHaveAttribute("data-keyboard-scroll-complete", "true"); - let end = await stage.evaluate(element => { - element.scrollLeft = element.scrollWidth; - return { actual: element.scrollLeft, maximum: element.scrollWidth - element.clientWidth }; - }); - expect(end.actual).toBeGreaterThanOrEqual(end.maximum - 1); - await stage.evaluate(element => element.scrollLeft = 0); - await expect.poll(() => stage.evaluate(element => element.scrollLeft)).toBe(0); await page.screenshot({ path: testInfo.outputPath(`astro-${viewport.width}.png`) }); } + // At actual size the drawing overflows the phone column and scrolls by keyboard. + let actual = preview.getByRole("button", { name: "Actual size", exact: true }); + await actual.click(); + await expect(actual).toHaveAttribute("aria-pressed", "true"); + await expect.poll(() => stage.evaluate(element => element.scrollWidth > element.clientWidth)) + .toBe(true); + await stage.focus(); + await stage.evaluate(element => { + element.setAttribute("data-keyboard-scroll-complete", "false"); + element.addEventListener("scrollend", () => { + element.setAttribute("data-keyboard-scroll-complete", "true"); + }, { once: true }); + }); + await stage.press("ArrowRight"); + await expect.poll(() => stage.evaluate(element => element.scrollLeft)).toBeGreaterThan(0); + await expect(stage).toHaveAttribute("data-keyboard-scroll-complete", "true"); + let end = await stage.evaluate(element => { + element.scrollLeft = element.scrollWidth; + return { actual: element.scrollLeft, maximum: element.scrollWidth - element.clientWidth }; + }); + expect(end.actual).toBeGreaterThanOrEqual(end.maximum - 1); + await stage.evaluate(element => element.scrollLeft = 0); + await expect.poll(() => stage.evaluate(element => element.scrollLeft)).toBe(0); + await svg.getByRole("button", { name: "Astro project", exact: true }).click(); await expect(preview.getByRole("complementary", { name: "Diagram details" })) .toBeVisible(); - let nativeWidth = await svg.evaluate(element => element.getBoundingClientRect().width); await preview.getByRole("button", { name: "Fit to width", exact: true }).click(); - await expect(preview.getByLabel("Diagram zoom", { exact: true })).toHaveText("85%"); - await expect.poll(() => svg.evaluate(element => element.getBoundingClientRect().width)) - .toBeCloseTo(nativeWidth * 0.85, 0); - await preview.getByRole("button", { name: "Zoom in diagram", exact: true }).click(); + await expect.poll(() => + stage.evaluate(element => + element.querySelector("svg")!.getBoundingClientRect().width - element.clientWidth + ) + ).toBeLessThanOrEqual(1); + // A chosen size survives resizing. + await actual.click(); + await page.setViewportSize({ width: 1440, height: 960 }); await expect(preview.getByLabel("Diagram zoom", { exact: true })).toHaveText("100%"); await preview.getByRole("button", { name: "Zoom in diagram", exact: true }).click(); await expect(preview.getByLabel("Diagram zoom", { exact: true })).toHaveText("115%"); @@ -146,6 +152,64 @@ test( }, ); +test("a chart wider than a phone column fits it from the first frame", async ({ join, seed }) => { + let chart = { + type: "bar", + title: "Median startup", + unit: "ms", + data: [["Baseline", 240], ["Cached", 183], ["Cached large", 410]], + }; + await seed(`# Startup\n\n\`\`\`seecode\n${JSON.stringify(chart)}\n\`\`\`\n`); + let page = await join("ana", { + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + }); + // Record the drawing's width in the frame it first appears, before any measurement settles. + await page.addInitScript(() => { + let observer = new MutationObserver(() => { + let svg = document.querySelector(".ch-diagram__stage svg.sc-svg"); + if (!svg) return; + observer.disconnect(); + requestAnimationFrame(() => { + let stage = svg.parentElement!; + document.documentElement.dataset.firstFrame = JSON.stringify({ + drawn: svg.getBoundingClientRect().width, + authored: svg.viewBox.baseVal.width, + stage: stage.clientWidth, + }); + }); + }); + observer.observe(document, { childList: true, subtree: true }); + }); + await page.reload(); + let preview = content(page).getByRole("region", { name: "Diagram preview" }); + await expect(preview.locator("svg.sc-svg")).toBeVisible(); + await expect(page.locator("html[data-first-frame]")).toHaveCount(1); + let first = JSON.parse((await page.locator("html").getAttribute("data-first-frame"))!); + expect(first.authored).toBeGreaterThan(first.stage); + expect(first.drawn).toBeLessThanOrEqual(first.stage + 1); + + // One row of controls: play, step, fit and actual size. + let controls = preview.getByRole("group", { name: "Diagram controls", exact: true }); + let button = (name: string) => controls.getByRole("button", { name, exact: true }); + await expect(button("Fit to width")).toHaveAttribute("aria-pressed", "true"); + await expect(button("Zoom in diagram")).toBeHidden(); + let tops = await controls.locator("button:visible").evaluateAll(buttons => + new Set(buttons.map(button => Math.round(button.getBoundingClientRect().top))).size + ); + expect(tops).toBe(1); + await button("Actual size").tap(); + await expect(button("Actual size")).toHaveAttribute("aria-pressed", "true"); + await expect.poll(() => + preview.locator(".ch-diagram__stage").evaluate(stage => stage.scrollWidth > stage.clientWidth) + ).toBe(true); + await button("Fit to width").tap(); + await expect.poll(() => + preview.locator(".ch-diagram__stage").evaluate(stage => stage.scrollWidth <= stage.clientWidth) + ).toBe(true); +}); + test("a saved SeeCode block renders for two readers and revises through shared source", async ({ join, room, seed }) => { await seed( `# Request path\n\nThe web client sends requests to the API.\n\n\`\`\`seecode\n${ diff --git a/packages/diagrams/src/diagram.tsx b/packages/diagrams/src/diagram.tsx index 823723be4..b83804c2d 100644 --- a/packages/diagrams/src/diagram.tsx +++ b/packages/diagrams/src/diagram.tsx @@ -103,7 +103,8 @@ function DiagramView({ let [playback, setPlayback] = useState(0); let [overflowing, setOverflowing] = useState(false); let [available, setAvailable] = useState(0); - let [zoom, setZoom] = useState(1); + // Null follows the stage: fitted to its width when wider, otherwise actual size. + let [zoom, setZoom] = useState(null); let graph = result.graph; let nodes = graph?.nodes ?? []; let edges = graph?.edges ?? []; @@ -151,7 +152,8 @@ function DiagramView({ return () => clearTimeout(timer); }, [reachedEnd, playback, maxStep, reduced]); - useEffect(() => { + // Before paint, so the first frame already knows whether the controls are needed. + useLayoutEffect(() => { let stage = stageRef.current; let svg = svgRef.current; if (!stage || !svg) return; @@ -313,7 +315,11 @@ function DiagramView({ ? edges.filter((edge) => edge.from === selectedNode.id || edge.to === selectedNode.id) : []; let stepping = maxStep > 0; - let zoomable = result.viewBox[2] > available || zoom !== 1; + let width = result.viewBox[2]; + let fit = available > 0 ? fitDiagram(width, available) : 1; + let scale = zoom ?? fit; + let minimumZoom = Math.min(DIAGRAM_VIEWPORT.minimumZoom, fit); + let zoomable = width > available || zoom !== null; let labelFor = (nodeId: string) => nodes.find((node) => node.id === nodeId)?.label ?? nodeId; let status = maxStep > 0 && !isRunning && (mode.kind === "paused" || reduced) ? `Step ${current} of ${maxStep}` @@ -334,7 +340,8 @@ function DiagramView({ -