diff --git a/tests/browser/example-board-ui.test.js b/tests/browser/example-board-ui.test.js
index a279b544..068111f8 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 `
`;
+// 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 = ``;
+ 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 99527081..3a8fca8a 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 76bc1fa2..5e9e6f68 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 6084c4e9..6a9d91d8 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 94bb122f..530cb40a 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 6031e85e..774202de 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 e434c31b..2ee046ef 100644
--- a/web/interview.html
+++ b/web/interview.html
@@ -397,38 +397,41 @@ Loading interview...
Example drawing
-
-
- Sketch an example while explaining it. Jim sees it after you pause
- drawing.
-