From d6e520063f022f0b8b757fae7fd473ec691f6665 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 4 Nov 2026 19:41:13 +0800 Subject: [PATCH] Expand the Example drawing over the editor panel Example drawing showed only a strip of its 16:10 canvas and scrolled the rest, about 30% of it at first and at most 65% with the divider dragged all the way up. A button on its toolbar now lays the board over the editor panel, whose code and tests are not needed while drawing, and shows the whole canvas while the sidebar stays usable. The strokes stay on the one canvas, so what Jim reads and what undo removes do not change on the way back, by the button, Escape or closing Example drawing. Neither the button nor Escape acts during a stroke or a text-box gesture, and Escape is left to a field, a dialog, an input method, a held key or a modifier chord. What the board covers cannot be reached from the keyboard while it is expanded: the rest of the panel is inert, Jim's full stage is hidden, and Cmd/Ctrl+Enter runs nothing, since its results would land in the covered drawer. The stage stays on top in the wait for the report, while audio is blocked, so Resume audio can be pressed, and while it holds the focus that button gives it; a narrow window keeps its caption card. Covering the full stage keeps the canvas's right column; his voice is unaffected and shrinking restores all of it. Close #579 Change-Id: I981fb4638856bc2a9a4e07fde1035b311c424a32 --- tests/browser/example-board-ui.test.js | 577 ++++++++++++++++++++++++- tests/browser/interview-layout.test.js | 248 +++++++++++ tests/browser/run-shortcut.test.js | 26 ++ web/board-text.js | 2 +- web/example-board.js | 64 ++- web/interview-drawer.js | 6 + web/interview.html | 61 +-- web/interview.js | 9 +- web/styles.css | 62 +++ 9 files changed, 1017 insertions(+), 38 deletions(-) diff --git a/tests/browser/example-board-ui.test.js b/tests/browser/example-board-ui.test.js index a279b5441..068111f85 100644 --- a/tests/browser/example-board-ui.test.js +++ b/tests/browser/example-board-ui.test.js @@ -17,7 +17,7 @@ before(async () => { handle: (_request, response, url) => { if (url.pathname === "/drawer") { response.setHeader("content-type", "text/html"); - response.end(drawerMarkup()); + response.end(drawerMarkup(url.searchParams.has("sidebar"))); return true; } if (url.pathname !== "/") return false; @@ -38,8 +38,23 @@ function exampleMarkup() { return html.slice(start, html.indexOf("", start) + 10); } -function drawerMarkup() { - return `
Add a case
${exampleMarkup()}
`; +// The real ending wait, so the board is stacked against the page's own +// overlay rules rather than a stand-in. +function endingMarkup() { + const html = read("web/interview.html"); + const start = html.lastIndexOf( + "", start) + 10); +} + +function drawerMarkup(sidebar = false) { + const panel = `
Add a case
${exampleMarkup()}
`; + const body = sidebar + ? `
${panel}${endingMarkup()}
` + : panel; + return `${body}`; } test("selecting an example pen color switches from erasing to drawing", async (t) => { @@ -799,7 +814,7 @@ test("offline drawings skip JPEG encoding and locked controls repaint", async (t await page.waitForFunction( () => window.uploads === 1 && - document.querySelector("#example-board > [role=status]").textContent === + document.querySelector(".example-pane > [role=status]").textContent === "Drawing ready for Jim to read when you refer to it.", ); assert.equal(await page.evaluate(() => window.encodings), 1); @@ -820,3 +835,557 @@ test("offline drawings skip JPEG encoding and locked controls repaint", async (t assert.equal(await page.locator("[data-action=clear]").isDisabled(), false); await page.evaluate(() => window.example.finish()); }); + +// The interview's own grid with a stand-in sidebar, so an expanded board can be +// checked against both the editor panel it covers and the sidebar it leaves. +async function openExpandableBoard(viewport) { + const page = await browser.newPage({ viewport }); + await page.goto(`${base}/drawer?sidebar`); + await page.waitForFunction( + () => + getComputedStyle(document.querySelector(".drawer-resizer")).height === + "8px", + ); + await page.evaluate(async (base) => { + const { mountExampleBoard } = await import(`${base}/example-board.js`); + const { mountInterviewDrawer } = await import( + `${base}/interview-drawer.js` + ); + window.ops = []; + window.uploads = []; + window.turns = 0; + document + .querySelector("#yield-turn") + .addEventListener("click", () => window.turns++); + mountExampleBoard(document.querySelector("#example-board"), { + locked: () => false, + record: (ops) => window.ops.push(...ops), + // Kept as the bytes Jim's read_board is handed, to decode later. + publish: async (blob, strokes) => { + const image = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + window.uploads.push({ strokes, image }); + return true; + }, + }); + mountInterviewDrawer(document.querySelector(".editor-panel")); + }, base); + await page.locator("#example-board summary").click(); + await page.waitForFunction(() => + document.querySelector(".example-surface").style.height.endsWith("px"), + ); + return page; +} + +function boardLayout(page) { + return page.evaluate(() => { + const box = (selector) => { + const { x, y, width, height } = document + .querySelector(selector) + .getBoundingClientRect(); + return { x, y, width, height }; + }; + const surface = document.querySelector(".example-surface"); + return { + panel: box(".editor-panel"), + pane: box(".example-pane"), + surface: box(".example-surface"), + canvas: box(".example-surface canvas"), + status: box(".example-pane > [role=status]"), + hidden: + surface.scrollHeight - + surface.clientHeight + + surface.scrollWidth - + surface.clientWidth, + }; + }); +} + +const isExpanded = (page) => + page + .locator("#example-board") + .evaluate((node) => node.hasAttribute("data-expanded")); + +async function drawAcross(page, canvas, fromX, toX, y) { + await page.mouse.move(canvas.x + canvas.width * fromX, canvas.y + y); + await page.mouse.down(); + await page.mouse.move(canvas.x + canvas.width * toX, canvas.y + y, { + steps: 4, + }); + await page.mouse.up(); +} + +// The darkest red channel around each board point, on the canvas or in an +// image sent to Jim; both keep the board's 1600x1000 pixels. +function inkAt(page, points, upload = null) { + return page.evaluate( + async ({ points, upload }) => { + let context = document + .querySelector(".example-surface canvas") + .getContext("2d"); + if (upload !== null) { + const image = new Image(); + image.src = window.uploads.at(upload).image; + await image.decode(); + context = document.createElement("canvas").getContext("2d"); + context.canvas.width = image.width; + context.canvas.height = image.height; + context.drawImage(image, 0, 0); + } + return points.map(([x, y]) => { + const { data } = context.getImageData(x, y - 3, 1, 7); + return Math.min(...data.filter((_, index) => index % 4 === 0)); + }); + }, + { points, upload }, + ); +} + +// The three sizes #579 measured. +for (const viewport of [ + { width: 1280, height: 800 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, +]) + test(`at ${viewport.width}x${viewport.height} the expanded example board shows the whole canvas over the editor panel only`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard(viewport); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + const lifted = await boardLayout(page); + assert.equal(lifted.hidden, 0); + assert.ok( + Math.abs(lifted.canvas.width / lifted.canvas.height - 1.6) < 0.01, + ); + assert.ok( + Math.abs(lifted.canvas.width - lifted.surface.width) <= 1 || + Math.abs(lifted.canvas.height - lifted.surface.height) <= 1, + "the canvas fills the width or the height it is given", + ); + const left = lifted.canvas.x - lifted.surface.x; + const right = + lifted.surface.x + + lifted.surface.width - + lifted.canvas.x - + lifted.canvas.width; + assert.ok(Math.abs(left - right) <= 1, "the canvas is centred"); + // The pane is the panel inside its 1px border, and the drawing takes the + // height its toolbar and status line leave. + for (const side of ["x", "y", "width", "height"]) + assert.ok(Math.abs(lifted.pane[side] - lifted.panel[side]) <= 2, side); + assert.ok( + lifted.pane.y + + lifted.pane.height - + lifted.status.y - + lifted.status.height <= + 16, + ); + await page.getByRole("button", { name: "Your turn is done" }).click(); + assert.equal(await page.evaluate(() => window.turns), 1); + }); + +test("strokes drawn before and while expanded stay on the canvas, in Jim's image and undoable", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + // Board y=100 in the drawer, then board y=500 once expanded. + const docked = await boardLayout(page); + await drawAcross(page, docked.canvas, 0.2, 0.6, docked.canvas.height * 0.1); + await page.waitForFunction(() => window.uploads.length === 1); + await page.getByRole("button", { name: "Expand drawing" }).click(); + const { canvas } = await boardLayout(page); + assert.ok((await inkAt(page, [[640, 100]]))[0] < 128); + await drawAcross(page, canvas, 0.2, 0.6, canvas.height / 2); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + + const [before, during, blank] = await inkAt(page, [ + [640, 100], + [640, 500], + [640, 800], + ]); + assert.ok(before < 128 && during < 128 && blank > 200); + // What Jim reads is the sent image, not the screen. + await page.waitForFunction(() => window.uploads.at(-1).strokes === 2); + const sent = await inkAt( + page, + [ + [640, 100], + [640, 500], + [640, 800], + ], + -1, + ); + assert.ok(sent[0] < 128, "Jim's image keeps the stroke from the drawer"); + assert.ok(sent[1] < 128, "Jim's image has the stroke drawn while expanded"); + assert.ok(sent[2] > 200, "Jim's image is not dark everywhere"); + + await page.getByRole("button", { name: "Undo", exact: true }).click(); + const [kept, undone] = await inkAt(page, [ + [640, 100], + [640, 500], + ]); + assert.ok(kept < 128 && undone > 200); +}); + +test("the button or Escape shrinks the board, but not from a sidebar field or mid-stroke", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const docked = await boardLayout(page); + const expand = page.getByRole("button", { name: "Expand drawing" }); + const shrink = page.getByRole("button", { name: "Shrink drawing" }); + const turn = page.getByRole("button", { name: "Your turn is done" }); + const icon = await expand.innerHTML(); + await expand.click(); + // The label names the action, so the button is not also a pressed toggle. + assert.equal(await shrink.getAttribute("aria-pressed"), null); + assert.equal(await shrink.getAttribute("title"), "Shrink drawing"); + assert.notEqual(await shrink.innerHTML(), icon); + await shrink.click(); + assert.equal(await isExpanded(page), false); + await expand.click(); + const { canvas } = await boardLayout(page); + + // A field outside the board keeps Escape for itself, as a select does. + await page.locator("#sidebar-field").focus(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), true); + + // Mid-stroke, neither Escape nor the focused button changes the canvas size. + await shrink.focus(); + await page.mouse.move(canvas.x + 100, canvas.y + 200); + await page.mouse.down(); + await page.mouse.move(canvas.x + 300, canvas.y + 300, { steps: 3 }); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), true); + await page.keyboard.press("Enter"); + assert.equal(await isExpanded(page), true); + await page.mouse.up(); + + // A sidebar button has no use for Escape, so from there it shrinks the board. + await turn.click(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false); + assert.equal(await expand.getAttribute("title"), "Expand drawing"); + assert.equal(await expand.innerHTML(), icon); + // Focus stays in the sidebar the key came from. + assert.equal( + await page.evaluate(() => document.activeElement.id), + "yield-turn", + ); + const back = await boardLayout(page); + assert.ok(Math.abs(back.canvas.width - docked.canvas.width) <= 1); +}); + +// Escape never moves focus: from the board's own button, a tool, or the +// sidebar, the candidate stays where they were. +test("shrinking by Escape leaves focus where it was, in the board or the sidebar", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const toggle = page.locator("#example-board [data-action=expand]"); + const focused = (locator) => + locator.evaluate((node) => node === document.activeElement); + for (const from of [ + toggle, + page.getByRole("button", { name: "Pen", exact: true }), + page.getByRole("button", { name: "Your turn is done" }), + ]) { + await toggle.click(); + assert.equal(await isExpanded(page), true); + await from.focus(); + await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false); + assert.equal( + await focused(from), + true, + (await from.getAttribute("aria-label")) ?? (await from.textContent()), + ); + } + // The button is the same element either way, so it keeps focus through the + // label change. + assert.equal(await toggle.getAttribute("aria-label"), "Expand drawing"); +}); + +test("Escape from an input method, a modifier chord or a held key leaves the board expanded", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.getByRole("button", { name: "Text", exact: true }).click(); + const { canvas } = await boardLayout(page); + await page.mouse.click(canvas.x + 120, canvas.y + 120); + const label = page.getByRole("textbox", { name: "Label text" }); + await label.fill("ab"); + // An IME composing in the label ends with an Escape the draft lets through, + // flagged by either field depending on the browser. + for (const flag of [{ isComposing: true }, { keyCode: 229 }]) { + await label.evaluate( + (node, flag) => + node.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + ...flag, + }), + ), + flag, + ); + assert.equal(await isExpanded(page), true, JSON.stringify(flag)); + } + await page.getByRole("button", { name: "Your turn is done" }).focus(); + for (const chord of [ + "Alt+Escape", + "Shift+Escape", + "Control+Escape", + "Meta+Escape", + ]) { + await page.keyboard.press(chord); + assert.equal(await isExpanded(page), true, chord); + } + // A held key's repeats are not a new press. + await page.evaluate(() => + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + repeat: true, + bubbles: true, + }), + ), + ); + assert.equal(await isExpanded(page), true); +}); + +test("the covered editor panel and Jim's stage are out of reach while expanded and all come back on shrink", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const active = () => + page.evaluate( + () => document.activeElement.id || document.activeElement.tagName, + ); + // Every control the pane covers, not only the ones Shift+Tab passes, and + // whether the stage's button can take focus. + const reachable = () => + page.evaluate(() => { + const hide = document.querySelector("#jim-stage .hide-avatar"); + hide.focus(); + const stage = document.activeElement === hide; + hide.blur(); + return { + panel: [ + ...document + .querySelector(".editor-panel") + .querySelectorAll( + "button, input, select, textarea, summary, [tabindex]", + ), + ] + .filter((node) => !node.closest(".example-pane, [inert]")) + .map((node) => node.id || node.textContent.trim() || node.tagName), + stage, + }; + }); + const docked = await reachable(); + assert.ok(docked.panel.length > 3); + assert.equal(docked.stage, true); + // Twice, shrinking by the button and then by Escape from the sidebar. + for (const shrinkBy of ["button", "Escape"]) { + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.deepEqual(await reachable(), { panel: [], stage: false }, shrinkBy); + await page.getByRole("button", { name: "Shrink drawing" }).focus(); + await page.keyboard.press("Shift+Tab"); + assert.equal(await active(), "yield-turn", shrinkBy); + if (shrinkBy === "button") + await page.getByRole("button", { name: "Shrink drawing" }).click(); + else await page.keyboard.press("Escape"); + assert.equal(await isExpanded(page), false, shrinkBy); + assert.deepEqual(await reachable(), docked, shrinkBy); + } + await page.getByRole("button", { name: "Expand drawing" }).focus(); + await page.keyboard.press("Shift+Tab"); + assert.equal(await active(), "SUMMARY"); + await page.locator("#code").focus(); + assert.equal(await active(), "code"); +}); + +// An ink stroke and each text-box gesture, against each key that resizes the +// board, docked and expanded; each key is asserted alone so a wrongly obeyed +// one cannot be undone by the next. +test("Expand, Shrink and Escape are ignored during a stroke or a text-box gesture, and work once it ends", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const toggle = page.locator("#example-board [data-action=expand]"); + const draft = page.locator(".board-text-draft"); + const centre = (box) => ({ + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }); + async function holdsThrough(from, to, keys, what) { + const expanded = await isExpanded(page); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 3 }); + for (const key of keys) { + // A new text box leaves focus in its label, so the button is focused + // again first: otherwise Enter would never reach it and prove nothing. + if (key !== "Escape") { + await toggle.focus(); + assert.equal( + await page.evaluate(() => document.activeElement.dataset.action), + "expand", + ); + } + await page.keyboard.press(key); + assert.equal(await isExpanded(page), expanded, `${what}, ${key}`); + } + await page.mouse.up(); + } + for (const expanded of [false, true]) { + const state = expanded ? "expanded" : "docked"; + if (expanded) await toggle.click(); + // Escape last: on a text box the draft takes it and is cancelled, and that + // same press must not resize the board as well. + const keys = expanded ? ["Enter", " ", "Escape"] : ["Enter", " "]; + const { canvas } = await boardLayout(page); + const at = (x, y) => ({ x: canvas.x + x, y: canvas.y + y }); + const openDraft = async () => { + if ((await draft.count()) === 0) + await page.mouse.click(canvas.x + 80, canvas.y + 40); + }; + await page.getByRole("button", { name: "Pen", exact: true }).click(); + await holdsThrough(at(60, 40), at(200, 90), keys, `${state} stroke`); + await page.getByRole("button", { name: "Text", exact: true }).click(); + await holdsThrough(at(260, 30), at(420, 100), keys, `${state} new box`); + await openDraft(); + const edge = centre(await draft.locator("[data-edge=n]").boundingBox()); + await holdsThrough( + edge, + { x: edge.x + 30, y: edge.y + 10 }, + keys, + `${state} moved box`, + ); + await openDraft(); + const corner = centre( + await draft.locator('[data-handle="se"]').boundingBox(), + ); + await holdsThrough( + corner, + { x: corner.x + 30, y: corner.y + 10 }, + keys, + `${state} scaled box`, + ); + // Once the gesture is over, the button works again. + await toggle.focus(); + await page.keyboard.press("Enter"); + assert.equal(await isExpanded(page), !expanded, `${state}, after`); + if (!expanded) await toggle.click(); + if ((await draft.count()) > 0) + await page.getByRole("textbox", { name: "Label text" }).press("Escape"); + } +}); + +test("the ending wait covers an expanded board and keeps its own Escape", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page + .locator("#ending-overlay") + .evaluate((node) => (node.hidden = false)); + const { pane } = await boardLayout(page); + const covered = await page.evaluate( + ([x, y]) => + Boolean(document.elementFromPoint(x, y).closest("#ending-overlay")), + [pane.x + pane.width / 2, pane.y + pane.height / 2], + ); + assert.equal(covered, true); + // Escape inside that dialog is not the hidden board's to take. + await page + .locator("#ending-overlay") + .evaluate((node) => + node.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ), + ); + assert.equal(await isExpanded(page), true); +}); + +test("an expanded board hides Jim's full stage outside the ending wait, so Tab skips it", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const hidden = () => + page.evaluate( + () => getComputedStyle(document.querySelector("#jim-stage")).visibility, + ); + // Tab on from Shrink until focus leaves the board. + const landsIn = async () => { + await page.getByRole("button", { name: "Shrink drawing" }).focus(); + for (let presses = 0; presses < 20; presses++) { + await page.keyboard.press("Tab"); + const where = await page.evaluate(() => { + const node = document.activeElement; + if (node.closest(".example-pane")) return null; + return node.closest("#jim-stage") ? "stage" : "elsewhere"; + }); + if (where) return where; + } + return "the board"; + }; + assert.equal(await hidden(), "visible"); + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.equal(await hidden(), "hidden"); + assert.equal(await landsIn(), "elsewhere"); + // The wait lifts the stage over everything so Jim's closing lines are seen. + await page + .locator("#ending-overlay") + .evaluate((node) => (node.hidden = false)); + assert.equal(await hidden(), "visible"); + await page + .locator("#ending-overlay") + .evaluate((node) => (node.hidden = true)); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + assert.equal(await hidden(), "visible"); +}); + +test("closing Example drawing shrinks an expanded board and frees the editor", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.locator("#example-board").evaluate((node) => (node.open = false)); + // The details element fires toggle on a later task. + await page.waitForFunction( + () => + !document.querySelector("#example-board").hasAttribute("data-expanded"), + ); + assert.equal( + await page.evaluate( + () => document.querySelectorAll(".editor-panel [inert]").length, + ), + 0, + ); +}); + +test("a click where Hide avatar sits reaches the expanded board, not the stage", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await openExpandableBoard({ width: 1280, height: 800 }); + t.after(() => page.close()); + const hide = await page + .getByRole("button", { name: "Hide avatar" }) + .boundingBox(); + const topAt = () => + page.evaluate( + ({ x, y }) => + document.elementFromPoint(x, y).closest(".example-pane, .jim-stage") + ?.className, + { x: hide.x + hide.width / 2, y: hide.y + hide.height / 2 }, + ); + assert.equal(await topAt(), "jim-stage"); + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.equal(await topAt(), "example-pane"); +}); diff --git a/tests/browser/interview-layout.test.js b/tests/browser/interview-layout.test.js index 99527081c..3a8fca8a8 100644 --- a/tests/browser/interview-layout.test.js +++ b/tests/browser/interview-layout.test.js @@ -280,6 +280,254 @@ test("the wait keeps Jim heard and the recording withdrawable", async (t) => { } }); +// #579's own case: a 13.6-inch MacBook Air window with the divider at its +// maximum, where the editor panel overflows by a few pixels. The expanded +// board must not scroll with it and show what it covers. +test("the expanded Example drawing stays at the panel's top when the panel could scroll", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1250, height: 630 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.locator(".drawer-resizer").focus(); + await page.keyboard.press("End"); + const offset = () => + page.evaluate(() => { + const panel = document.querySelector(".editor-panel"); + const pane = document.querySelector(".example-pane"); + return Math.round( + pane.getBoundingClientRect().top - panel.getBoundingClientRect().top, + ); + }); + // Scrolled before expanding, and wheeled over the board after. + const scrolled = await page + .locator(".editor-panel") + .evaluate( + (panel) => (panel.scrollTop = panel.scrollHeight) && panel.scrollTop, + ); + assert.ok(scrolled > 0, "the panel can scroll at this size"); + await page.getByRole("button", { name: "Expand drawing" }).click(); + assert.ok( + Math.abs(await offset()) <= 1, + "the pane starts at the panel's top", + ); + const pane = await page.locator(".example-pane").boundingBox(); + await page.mouse.move(pane.x + pane.width / 2, pane.y + pane.height / 2); + await page.mouse.wheel(0, 300); + // The wheel scrolls on a later frame. + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + assert.ok(Math.abs(await offset()) <= 1, "and stays there under the wheel"); + } finally { + await page.close(); + } +}); + +// The drawer sizes the docked drawing from its own layout, which an expanded +// board is not in: a resize while expanded must not leave a wrong height +// behind for the docked board to come back to. +test("shrinking after a resize while expanded sizes the docked drawing for the new window", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + // The drawer repaints from observers, a frame or two after a change. + const settled = () => + page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + const surface = () => + page + .locator(".example-surface") + .evaluate((node) => node.getBoundingClientRect().height); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.setViewportSize({ width: 1280, height: 600 }); + await settled(); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + await settled(); + const back = await surface(); + // What the drawer gives the drawing at this size when it paints afresh. + await page + .locator("#results-body") + .evaluate((node) => (node.hidden = !node.hidden)); + await settled(); + await page + .locator("#results-body") + .evaluate((node) => (node.hidden = !node.hidden)); + await settled(); + assert.ok(Math.abs(back - (await surface())) <= 1, `${back}px`); + } finally { + await page.close(); + } +}); + +// Each of the page's own overlays over an expanded board, on the real page: +// the pane sits under them, so whichever one opens is what the candidate sees. +test("an expanded Example drawing stays under the setup check, the ending wait and the report", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage({ + viewport: { width: 1280, height: 800 }, + }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.getByRole("button", { name: "Expand drawing" }).click(); + for (const overlay of [ + "#audio-check", + "#ending-overlay", + "#report-modal", + ]) { + const covered = await page.evaluate((overlay) => { + const node = document.querySelector(overlay); + node.hidden = false; + const pane = document + .querySelector(".example-pane") + .getBoundingClientRect(); + const hit = document.elementFromPoint( + pane.x + pane.width / 2, + pane.y + pane.height / 2, + ); + node.hidden = true; + return node.contains(hit); + }, overlay); + assert.ok(covered, overlay); + } + } finally { + await page.close(); + } +}); + +// On the real page, wide and narrow. The full stage is hidden, so no covered +// control is left in the Tab order, until audio is blocked and Resume audio has +// to be reachable, and while it holds the focus Resume audio gives it; the +// compact card keeps its captions on top of the board. +test("an expanded Example drawing hides Jim's full stage, but not the compact card or Resume audio", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + for (const [width, height, compact] of [ + [1440, 900, false], + [900, 800, true], + ]) { + const size = `${width}x${height}`; + const page = await browser.newPage({ viewport: { width, height } }); + try { + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&exampleDrawing=1`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction( + () => + document.querySelector("#candidate-case-input").placeholder !== "", + ); + await closeSetupCheck(page); + await page + .locator("#example-board") + .evaluate((node) => (node.open = true)); + assert.equal( + await page + .locator("#jim-stage") + .evaluate((node) => node.classList.contains("compact")), + compact, + size, + ); + const visibility = () => + page.evaluate( + () => + getComputedStyle(document.querySelector("#jim-stage")).visibility, + ); + // Whether the element at the centre of `selector` is inside it. + const onTop = (selector) => + page.evaluate((selector) => { + const node = document.querySelector(selector); + const box = node.getBoundingClientRect(); + return node.contains( + document.elementFromPoint( + box.x + box.width / 2, + box.y + box.height / 2, + ), + ); + }, selector); + assert.equal(await visibility(), "visible", size); + await page.getByRole("button", { name: "Expand drawing" }).click(); + if (compact) { + assert.equal(await visibility(), "visible", size); + assert.ok(await onTop("#captions-bar"), size); + } else { + assert.equal(await visibility(), "hidden", size); + } + await page.evaluate( + () => (document.querySelector("#audio-playback-notice").hidden = false), + ); + assert.ok(await onTop("#resume-audio"), size); + // What audio-playback.js does once Resume audio plays, done here by + // script: focus moves to the stage, then the notice goes. The stage + // keeps focus and stays shown, so its "Audio restored." is not hidden. + await page.locator("#resume-audio").focus(); + await page.evaluate(() => { + document.querySelector("#jim-stage").focus(); + document.querySelector("#audio-playback-notice").hidden = true; + }); + assert.equal( + await page.evaluate(() => document.activeElement.id), + "jim-stage", + size, + ); + assert.equal(await visibility(), "visible", size); + assert.ok( + await onTop(compact ? "#captions-bar" : "#jim-stage .hide-avatar"), + size, + ); + assert.ok( + await page.evaluate( + () => + getComputedStyle(document.querySelector("#audio-playback-status")) + .visibility === "visible", + ), + size, + ); + // Once focus moves on, the full stage goes again. + await page.locator("#yield-turn").focus(); + assert.equal(await visibility(), compact ? "visible" : "hidden", size); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + assert.equal(await visibility(), "visible", size); + } finally { + await page.close(); + } + } +}); + // On a phone the compact stage runs along the bottom edge above the wait, so a // long closing line must not grow it over the wait's own buttons. test("on a phone Jim's closing lines leave the wait's buttons reachable", async (t) => { diff --git a/tests/browser/run-shortcut.test.js b/tests/browser/run-shortcut.test.js index 76bc1fa24..5e9e6f68d 100644 --- a/tests/browser/run-shortcut.test.js +++ b/tests/browser/run-shortcut.test.js @@ -410,6 +410,32 @@ test("the shortcut does nothing while the media preflight is still up", async (t } }); +test("the shortcut runs nothing while Example drawing is expanded, and runs again once it shrinks", async (t) => { + const ctx = await interviewPage(t, { params: { exampleDrawing: "1" } }); + if (!ctx) return; + const { page, errors } = ctx; + try { + await runButtonReady(page); + await page.locator("#example-board").evaluate((node) => (node.open = true)); + await page.getByRole("button", { name: "Expand drawing" }).click(); + await page.keyboard.press("ControlOrMeta+Enter"); + // Taken, so it presses nothing else, and runs nothing while the board + // covers the results. + assert.equal(await runs(page), 0); + assert.equal(await enterDefaultPrevented(page), true); + await page.getByRole("button", { name: "Shrink drawing" }).click(); + await page.keyboard.press("ControlOrMeta+Enter"); + assert.equal(await runs(page), 1); + assert.deepEqual( + errors, + [], + `the shortcut threw in the browser: ${errors[0]}`, + ); + } finally { + await page.close(); + } +}); + test("the shortcut does nothing once the interview has ended", async (t) => { const ctx = await interviewPage(t, { clock: true }); if (!ctx) return; diff --git a/web/board-text.js b/web/board-text.js index 6084c4e94..6a9d91d8a 100644 --- a/web/board-text.js +++ b/web/board-text.js @@ -455,5 +455,5 @@ export function mountBoardText( canvas.addEventListener("keydown", onDraftKeydown); const observer = new ResizeObserver(refresh); observer.observe(canvas); - return { refresh, cancel: close }; + return { refresh, cancel: close, busy: () => gesture !== null }; } diff --git a/web/example-board.js b/web/example-board.js index 94bb122fe..530cb40ac 100644 --- a/web/example-board.js +++ b/web/example-board.js @@ -16,6 +16,8 @@ const TOOL_ICONS = { eraser: '', undo: '', clear: '', + expand: '', + shrink: '', }; function setToolIcon(button, name) { @@ -44,7 +46,13 @@ export function mountExampleBoard( const model = createBoard(); const pointers = createPointerGate(); const tools = root.querySelector("[data-tools]"); - const status = root.querySelector(":scope > [role=status]"); + // The interview wraps the board in `.example-pane` so it can lift out of + // the drawer; the task page keeps it directly under the details element. + const status = root.querySelector( + ":scope > [role=status], :scope > .example-pane > [role=status]", + ); + const expand = root.querySelector("[data-action=expand]"); + const pane = root.querySelector(":scope > .example-pane"); let tool = "pen"; let color = PEN_COLORS[0].color; let drag = null; @@ -269,6 +277,60 @@ export function mountExampleBoard( }); canvas.addEventListener("pointercancel", cancel); canvas.addEventListener("lostpointercapture", cancel); + // Expanding moves no element: the pane is restyled in place, so the canvas, + // its strokes and anything waiting to reach Jim stay exactly as they were. + // Only the drawn size changes, and `boardPoint` already scales by it. + function setExpanded(on) { + root.toggleAttribute("data-expanded", on); + setToolIcon(expand, on ? "shrink" : "expand"); + expand.setAttribute("aria-label", on ? "Shrink drawing" : "Expand drawing"); + expand.title = expand.getAttribute("aria-label"); + const panel = root.closest(".editor-panel"); + // The panel can scroll a little on a short screen, and the pane would + // scroll with it; the stylesheet stops it scrolling while expanded. + if (on && panel) panel.scrollTop = 0; + // What the pane covers leaves the tab order with it, or Shift+Tab walks + // into a code editor nobody can see. The sidebar is outside the panel. + for (let node = pane; panel && node !== panel; node = node.parentElement) + for (const sibling of node.parentElement.children) + if (sibling !== node) sibling.inert = on; + } + if (expand) { + setExpanded(false); + // Nothing on the page closes it while expanded, but whatever does must + // not leave the editor inert under a board that is gone. + root.addEventListener("toggle", () => { + if (!root.open) setExpanded(false); + }); + expand.addEventListener("click", () => { + if (!drag && !textDraft.busy()) + setExpanded(!root.hasAttribute("data-expanded")); + }); + // Not a modal dialog: the sidebar's turn and mute controls stay usable + // while the board is large, so Escape is handled here rather than by + // `showModal`. A text draft claims Escape first and stops it. An ink + // stroke takes no focus, so after a sidebar button the key still arrives + // there. A field or a dialog keeps Escape for itself, and so does an + // input method, a modifier chord or a held key. + document.addEventListener("keydown", (event) => { + if ( + event.key !== "Escape" || + event.isComposing || + event.keyCode === 229 || + event.repeat || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + drag || + textDraft.busy() || + !root.hasAttribute("data-expanded") || + event.target.closest?.("input, textarea, select, [role=dialog]") + ) + return; + setExpanded(false); + }); + } root.addEventListener("toggle", () => { if (!root.open) cancelTurn?.(); if (!root.open && drag) { diff --git a/web/interview-drawer.js b/web/interview-drawer.js index 6031e85e3..774202de5 100644 --- a/web/interview-drawer.js +++ b/web/interview-drawer.js @@ -107,6 +107,12 @@ export function mountInterviewDrawer(panel) { }); for (const details of drawer.querySelectorAll("details")) details.addEventListener("toggle", paint); + // A shrinking board comes back to a drawer that may have been resized while + // it was out, so its docked height is measured again. + new MutationObserver(paint).observe(example, { + attributes: true, + attributeFilter: ["data-expanded"], + }); new MutationObserver(paint).observe(results, { attributes: true, attributeFilter: ["hidden"], diff --git a/web/interview.html b/web/interview.html index e434c31b4..2ee046efb 100644 --- a/web/interview.html +++ b/web/interview.html @@ -397,38 +397,41 @@

Loading interview...