diff --git a/docs/avatar-contract.md b/docs/avatar-contract.md
index fb31b40a..d99188d2 100644
--- a/docs/avatar-contract.md
+++ b/docs/avatar-contract.md
@@ -305,8 +305,10 @@ drawing tools at the board) stay visible at every width the avatar is shown. The
stylesheet's `4.25rem` is only the fallback before that first measurement. The
stage takes no pointer events, so it can never swallow a click meant for the
editor; the Hide avatar button and the captions re-enable them for themselves.
-Below 1200px wide the avatar and its button are hidden rather than allowed to
-cover code, and the captions move to the bottom corner.
+Below 1200px wide, beside an editor panel too narrow for the avatar, or over an
+expanded Example drawing, the avatar and its button are hidden rather than
+allowed to cover code or the drawing, and the captions move to the bottom
+corner.
Every VRM loads in a T-pose and the format ships no idle animation, so
`loadVrm` drops the upper arms once at load. This is model-independent: without
@@ -447,7 +449,8 @@ button; if the button held focus, focus moves to the stage, since the box that
would have held a note is removed.
The render loop stops scheduling frames while the document is hidden, the stage
-is narrower than the breakpoint, or the preflight overlay covers it. It starts
+is compact (below the breakpoint, beside a narrow editor panel, or over an
+expanded Example drawing), or the preflight overlay covers it. It starts
again on `visibilitychange`, when a `ResizeObserver` on the mount sees it widen,
and when the page reports the preflight closed (`setStageCovered(false)`). The
stage is `inert` while covered, so nothing on it can be reached by keyboard
diff --git a/tests/browser/example-board-ui.test.js b/tests/browser/example-board-ui.test.js
index a279b544..ec719a96 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,638 @@ 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.uploads = [];
+ window.turns = 0;
+ document
+ .querySelector("#yield-turn")
+ .addEventListener("click", () => window.turns++);
+ mountExampleBoard(document.querySelector("#example-board"), {
+ locked: () => false,
+ // 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();
+ const turn = page.getByRole("button", { name: "Your turn is done" });
+ // An input method's Escape, flagged by either field depending on the
+ // browser, sent from outside any field so the field guard cannot stop it.
+ for (const flag of [{ isComposing: true }, { keyCode: 229 }]) {
+ await turn.evaluate(
+ (node, flag) =>
+ node.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Escape",
+ bubbles: true,
+ ...flag,
+ }),
+ ),
+ flag,
+ );
+ assert.equal(await isExpanded(page), true, JSON.stringify(flag));
+ }
+ await turn.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 is out of reach while expanded and all of it comes 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.
+ const reachable = () =>
+ page.evaluate(() =>
+ [
+ ...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),
+ );
+ const docked = await reachable();
+ assert.ok(docked.length > 3);
+ // 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(), [], 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 the button works 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();
+ // Every key goes to the focused button, so Escape reaches the board's own
+ // handler rather than the draft's, which listens on the box and canvas.
+ const keys = expanded ? ["Enter", " ", "Escape"] : ["Enter", " "];
+ const { canvas } = await boardLayout(page);
+ const at = (x, y) => ({ x: canvas.x + x, y: canvas.y + y });
+ 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`);
+ // The box the new-box step drew stays open for the move and the scale.
+ 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`,
+ );
+ 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();
+ // A closed draft is hidden rather than removed, so it is open when shown.
+ if (await draft.isVisible())
+ 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 covered 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("shrinking gives back only what expanding made inert, round after round, and a docked close nothing", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await openExpandableBoard({ width: 1280, height: 800 });
+ t.after(() => page.close());
+ const inert = () =>
+ page.evaluate(() => [
+ document.querySelector(".drawer-resizer").inert,
+ document.querySelector(".editor-stack").inert,
+ ]);
+ const roundTrip = async () => {
+ await page.getByRole("button", { name: "Expand drawing" }).click();
+ await page.getByRole("button", { name: "Shrink drawing" }).click();
+ };
+ // Something else holds the divider inert before the board expands.
+ await page.locator(".drawer-resizer").evaluate((node) => (node.inert = true));
+ await roundTrip();
+ assert.deepEqual(await inert(), [true, false]);
+ // The next round gives back only what it made inert: the editor, which
+ // something else has held inert since the last shrink, stays so.
+ await page.locator(".editor-stack").evaluate((node) => (node.inert = true));
+ await roundTrip();
+ assert.deepEqual(await inert(), [true, true]);
+ // Closing Example drawing while docked has nothing to give back, so a node
+ // the last round froze and something else holds inert since stays so.
+ const results = page.locator(".test-drawer > button");
+ await results.evaluate((node) => (node.inert = true));
+ // The details element fires toggle on a later task; waiting on a flag the
+ // event sets fails on the wait's own deadline if it never comes.
+ await page.locator("#example-board").evaluate((node) => {
+ node.addEventListener("toggle", () => (globalThis.boardClosed = true), {
+ once: true,
+ });
+ node.open = false;
+ });
+ await page.waitForFunction(() => globalThis.boardClosed === true);
+ assert.equal(await results.evaluate((node) => node.inert), true);
+});
+
+test("a click that leaves focus in the editor moves it to Shrink", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await openExpandableBoard({ width: 1280, height: 800 });
+ t.after(() => page.close());
+ await page.locator("#code").focus();
+ // Safari and Firefox on macOS do not focus a clicked button; a scripted
+ // click does the same here.
+ await page
+ .locator("#example-board [data-action=expand]")
+ .evaluate((node) => node.click());
+ assert.equal(await isExpanded(page), true);
+ assert.equal(
+ await page.evaluate(() =>
+ document.activeElement.getAttribute("aria-label"),
+ ),
+ "Shrink drawing",
+ );
+});
+
+test("Escape another handler took, or one from a native dialog, 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();
+ // A handler that takes Escape with preventDefault alone, before it bubbles.
+ const turn = page.getByRole("button", { name: "Your turn is done" });
+ await turn.evaluate((node) =>
+ node.addEventListener(
+ "keydown",
+ (event) => event.key === "Escape" && event.preventDefault(),
+ { once: true },
+ ),
+ );
+ await turn.focus();
+ await page.keyboard.press("Escape");
+ assert.equal(await isExpanded(page), true, "taken by another handler");
+ // A native dialog carries no role attribute.
+ await page.evaluate(() => {
+ const dialog = document.createElement("dialog");
+ dialog.innerHTML = '';
+ document.body.append(dialog);
+ dialog.show();
+ });
+ await page.locator("#native-dialog-button").focus();
+ await page.keyboard.press("Escape");
+ assert.equal(await isExpanded(page), true, "from a native dialog");
+});
+
+// A text box near the canvas's bottom puts its controls below the canvas, so
+// the drawing still has room to scroll; expanding from a strip scrolled down to
+// it must show the canvas from its top.
+test("expanding with a text box at the bottom shows the canvas from its top", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ // #579's short window, where the expanded canvas takes the pane's full
+ // height and leaves no room below it for the box's controls.
+ const page = await openExpandableBoard({ width: 1250, height: 630 });
+ t.after(() => page.close());
+ await page.getByRole("button", { name: "Text", exact: true }).click();
+ const surface = page.locator(".example-surface");
+ await surface.evaluate((node) => (node.scrollTop = node.scrollHeight));
+ const box = await surface.boundingBox();
+ await page.mouse.click(box.x + 120, box.y + box.height - 20);
+ assert.ok(await page.locator(".board-text-draft").isVisible());
+ await surface.evaluate((node) => (node.scrollTop = node.scrollHeight));
+ await page.getByRole("button", { name: "Expand drawing" }).click();
+ const { canvas } = await boardLayout(page);
+ const top = await surface.evaluate(
+ (node) => node.getBoundingClientRect().top,
+ );
+ assert.ok(canvas.y >= top - 1, `the canvas starts ${top - canvas.y}px above`);
+});
+
+test("shrinking puts the docked drawing back where it was scrolled", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await openExpandableBoard({ width: 1280, height: 800 });
+ t.after(() => page.close());
+ // A panel narrower than the docked canvas's 32rem, as a widened sidebar
+ // leaves it, so the strip scrolls sideways as well as down.
+ await page
+ .locator(".editor-panel")
+ .evaluate((node) => (node.style.width = "400px"));
+ const strip = page.locator(".example-surface");
+ const scrolled = () => strip.evaluate((node) => node.scrollTop);
+ const across = () => strip.evaluate((node) => node.scrollLeft);
+ await strip.evaluate((node) => {
+ node.scrollTop = 100;
+ node.scrollLeft = 60;
+ });
+ assert.deepEqual([await scrolled(), await across()], [100, 60]);
+ await page.getByRole("button", { name: "Expand drawing" }).click();
+ await page.getByRole("button", { name: "Shrink drawing" }).click();
+ assert.deepEqual(
+ [await scrolled(), await across()],
+ [100, 60],
+ "after a round trip",
+ );
+});
+
+test("the Escape that shrinks the board is marked as taken, and a docked one is not", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await openExpandableBoard({ width: 1280, height: 800 });
+ t.after(() => page.close());
+ // A listener registered after the board's, as another part of the page is.
+ await page.evaluate(() =>
+ window.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") globalThis.taken = event.defaultPrevented;
+ }),
+ );
+ const taken = () => page.evaluate(() => globalThis.taken);
+ const turn = page.getByRole("button", { name: "Your turn is done" });
+ await page.getByRole("button", { name: "Expand drawing" }).click();
+ await turn.focus();
+ await page.keyboard.press("Escape");
+ assert.equal(await isExpanded(page), false);
+ assert.equal(await taken(), true, "the shrinking Escape");
+ await page.keyboard.press("Escape");
+ assert.equal(await taken(), false, "a docked Escape");
+});
+
+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,
+ );
+});
diff --git a/tests/browser/interview-layout.test.js b/tests/browser/interview-layout.test.js
index 99527081..38e4797e 100644
--- a/tests/browser/interview-layout.test.js
+++ b/tests/browser/interview-layout.test.js
@@ -280,6 +280,495 @@ 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, and the scroll comes back on shrink", 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");
+ // Shrinking puts the panel back where the candidate had scrolled it.
+ await page.getByRole("button", { name: "Shrink drawing" }).click();
+ assert.equal(
+ await page.locator(".editor-panel").evaluate((panel) => panel.scrollTop),
+ scrolled,
+ "the panel's scroll comes back on shrink",
+ );
+ } 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 neither measure it from the
+// floating box nor leave a wrong height for the docked board to come back to.
+test("a resize while expanded leaves the docked height alone until shrinking sizes it 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);
+ const reserved = () =>
+ page.locator(".example-surface").evaluate((node) => node.style.height);
+ // The divider at its maximum, which the shorter window then lowers, so
+ // the docked drawing must come back shorter than it left.
+ await page.locator(".drawer-resizer").focus();
+ await page.keyboard.press("End");
+ await settled();
+ const docked = await reserved();
+ await page.getByRole("button", { name: "Expand drawing" }).click();
+ await page.setViewportSize({ width: 1280, height: 600 });
+ await settled();
+ // The resize repaints the drawer, which leaves the expanded board's docked
+ // height alone rather than measuring it from the box the board floats in.
+ assert.equal(await reserved(), docked, "while expanded");
+ 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();
+ }
+});
+
+// A resize while expanded changes the docked strip's height, which the drawer
+// measures again only after the board shrinks; the strip's scroll waits for
+// that, or it is clamped against the old height and lands short.
+test("a resize while expanded keeps the docked strip's scroll on shrink", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await browser.newPage({
+ viewport: { width: 1440, height: 900 },
+ });
+ 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 settled = () =>
+ page.evaluate(
+ () =>
+ new Promise((done) =>
+ requestAnimationFrame(() => requestAnimationFrame(done)),
+ ),
+ );
+ await settled();
+ const strip = page.locator(".example-surface");
+ const scrolled = await strip.evaluate(
+ (node) => (node.scrollTop = node.scrollHeight) && node.scrollTop,
+ );
+ assert.ok(scrolled > 0, "the strip can scroll at this size");
+ await page.getByRole("button", { name: "Expand drawing" }).click();
+ await page.setViewportSize({ width: 1280, height: 700 });
+ await settled();
+ await page.getByRole("button", { name: "Shrink drawing" }).click();
+ await settled();
+ const back = await strip.evaluate((node) => ({
+ top: node.scrollTop,
+ max: node.scrollHeight - node.clientHeight,
+ }));
+ assert.ok(back.max >= scrolled, "the new strip can hold the old scroll");
+ assert.equal(back.top, scrolled);
+ } finally {
+ await page.close();
+ }
+});
+
+// The panel's own layers under an expanded board: the Test results chevron is
+// transformed, which would paint it over a board with no stacking order of its
+// own. Inert nodes are skipped by hit-testing, which is not what is painted,
+// so the chevron's covered ancestor is let back in for the one lookup.
+test("an expanded Example drawing is drawn over the editor panel's own layers", 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();
+ const onBoard = await page.evaluate(() => {
+ const chevron = document.querySelector("#results-chevron");
+ const covered = chevron.closest("[inert]");
+ covered.inert = false;
+ const box = chevron.getBoundingClientRect();
+ const hit = document.elementFromPoint(
+ box.x + box.width / 2,
+ box.y + box.height / 2,
+ );
+ covered.inert = true;
+ return Boolean(hit.closest(".example-pane"));
+ });
+ assert.ok(onBoard, "the chevron is under the board");
+ } 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: expanding makes Jim's stage the compact
+// card a narrow window already has, so the captions stay on top of the board
+// while the avatar and Hide avatar go; shrinking gives the wide window its full
+// stage back.
+test("an expanded Example drawing turns Jim's stage into the compact card over the board", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ for (const [width, height, docked] 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));
+ const compact = () =>
+ page
+ .locator("#jim-stage")
+ .evaluate((node) => node.classList.contains("compact"));
+ // 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 compact(), docked, size);
+ // The avatar's mount has no width, which is what stops its drawing, and
+ // Hide avatar goes with it; a wide window has both before expanding.
+ const avatar = () =>
+ page.evaluate(() => [
+ document.querySelector("#jim-avatar").clientWidth > 0,
+ getComputedStyle(document.querySelector("#jim-stage .hide-avatar"))
+ .display !== "none",
+ ]);
+ assert.deepEqual(await avatar(), [!docked, !docked], size);
+ await page.getByRole("button", { name: "Expand drawing" }).click();
+ // The class follows on the observer's microtask.
+ await page.waitForFunction(() =>
+ document.querySelector("#jim-stage").classList.contains("compact"),
+ );
+ assert.ok(await onTop("#captions-bar"), size);
+ assert.deepEqual(await avatar(), [false, false], size);
+ // The card's volume slider, under Audio controls, keeps Escape for itself.
+ await page
+ .locator("#jim-stage .jim-volume-controls")
+ .evaluate((node) => (node.open = true));
+ await page.locator("#jim-volume").focus();
+ assert.equal(
+ await page.evaluate(() => document.activeElement.id),
+ "jim-volume",
+ size,
+ );
+ await page.keyboard.press("Escape");
+ assert.equal(
+ await page
+ .locator("#example-board")
+ .evaluate((node) => node.hasAttribute("data-expanded")),
+ true,
+ size,
+ );
+ // Blocked audio puts Resume audio in the card, on top as well.
+ await page.evaluate(
+ () => (document.querySelector("#audio-playback-notice").hidden = false),
+ );
+ assert.ok(await onTop("#resume-audio"), size);
+ await page.evaluate(
+ () => (document.querySelector("#audio-playback-notice").hidden = true),
+ );
+ await page.getByRole("button", { name: "Shrink drawing" }).click();
+ await page.waitForFunction(
+ (docked) =>
+ document.querySelector("#jim-stage").classList.contains("compact") ===
+ docked,
+ docked,
+ );
+ } finally {
+ await page.close();
+ }
+ }
+});
+
+// Safari and Firefox on macOS leave focus where it was on a click, so it can
+// still be on Hide avatar when the card hides that button; it moves to the
+// stage, as when an automatic turn-off hides it.
+test("expanding with focus on Hide avatar moves it to Jim's stage", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await browser.newPage({
+ viewport: { width: 1440, height: 900 },
+ });
+ 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("#hide-avatar").focus();
+ // A resize that leaves the window wide keeps the button, and its focus.
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await page.evaluate(
+ () =>
+ new Promise((done) =>
+ requestAnimationFrame(() => requestAnimationFrame(done)),
+ ),
+ );
+ assert.equal(
+ await page.evaluate(() => document.activeElement.id),
+ "hide-avatar",
+ );
+ // A scripted click moves no focus, as those browsers' clicks do not.
+ await page
+ .locator("#example-board [data-action=expand]")
+ .evaluate((node) => node.click());
+ await page.waitForFunction(() =>
+ document.querySelector("#jim-stage").classList.contains("compact"),
+ );
+ assert.equal(
+ await page.evaluate(() => document.activeElement.id),
+ "jim-stage",
+ );
+ } finally {
+ await page.close();
+ }
+});
+
+// The wait for the report over an expanded board: its own rule lifts Jim's card
+// over the dialog so his closing lines are seen, and the board behind the
+// dialog is inert, so Escape from the card leaves it expanded.
+test("over an expanded Example drawing the ending wait lifts Jim's card and keeps Escape from the board", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await browser.newPage({
+ viewport: { width: 1440, height: 900 },
+ });
+ 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();
+ await page
+ .locator("#ending-overlay")
+ .evaluate((node) => (node.hidden = false));
+ // The page makes the workspace inert on the observer's microtask.
+ await page.waitForFunction(
+ () => document.querySelector(".editor-panel").inert,
+ );
+ const captions = await page.evaluate(() => {
+ const bar = document.querySelector("#captions-bar");
+ const box = bar.getBoundingClientRect();
+ return bar.contains(
+ document.elementFromPoint(
+ box.x + box.width / 2,
+ box.y + box.height / 2,
+ ),
+ );
+ });
+ assert.ok(captions, "the card's captions are over the wait");
+ await page.locator("#jim-stage").focus();
+ await page.keyboard.press("Escape");
+ assert.equal(
+ await page
+ .locator("#example-board")
+ .evaluate((node) => node.hasAttribute("data-expanded")),
+ true,
+ );
+ } finally {
+ await page.close();
+ }
+});
+
+// The whiteboard page has no Example drawing to expand, so only the window and
+// its panel decide: a wide one keeps Jim's full stage, resize after resize.
+test("the whiteboard page keeps Jim's full stage in a wide window", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await browser.newPage({
+ viewport: { width: 1440, height: 900 },
+ });
+ try {
+ await page.goto(
+ `${base}/interview.html?problem=chargeback-pair-match&mode=whiteboard`,
+ { waitUntil: "domcontentloaded" },
+ );
+ await page.waitForFunction(
+ () => document.querySelector(".editor-panel") === null,
+ );
+ const compact = () =>
+ page.evaluate(
+ () =>
+ new Promise((done) =>
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() =>
+ done(
+ document
+ .querySelector("#jim-stage")
+ .classList.contains("compact"),
+ ),
+ ),
+ ),
+ ),
+ );
+ assert.equal(await compact(), false);
+ for (const width of [1400, 1440]) {
+ await page.setViewportSize({ width, height: 900 });
+ assert.equal(await compact(), false, `${width}`);
+ }
+ } 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..c8bb44fb 100644
--- a/web/example-board.js
+++ b/web/example-board.js
@@ -16,10 +16,15 @@ const TOOL_ICONS = {
eraser: '',
undo: '',
clear: '',
+ expand: '',
+ shrink: '',
};
-function setToolIcon(button, name) {
- const label = name[0].toUpperCase() + name.slice(1);
+function setToolIcon(
+ button,
+ name,
+ label = name[0].toUpperCase() + name.slice(1),
+) {
button.setAttribute("aria-label", label);
button.title = name === "clear" ? "Clear drawing; Undo restores it" : label;
button.classList.add("example-tool-icon");
@@ -44,10 +49,21 @@ 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");
+ const panel = root.closest(".editor-panel");
+ const strip = canvas.parentElement;
let tool = "pen";
let color = PEN_COLORS[0].color;
let drag = null;
+ let scrollBack = 0;
+ let stripBack = [0, 0];
+ let covered = [];
let settle = null;
let unsentSince = 0;
let dirty = false;
@@ -269,13 +285,106 @@ 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 expandBoard() {
+ // Read before the restyle, which can clamp the drawing's scroll.
+ scrollBack = panel.scrollTop;
+ stripBack = [strip.scrollTop, strip.scrollLeft];
+ root.setAttribute("data-expanded", "");
+ setToolIcon(expand, "shrink", "Shrink drawing");
+ // The panel can scroll a little on a short screen, and the pane would
+ // scroll with it; the stylesheet stops it scrolling while expanded. A text
+ // box near the bottom keeps its controls below the canvas, so the drawing
+ // can still scroll; it starts from the canvas's top.
+ panel.scrollTop = 0;
+ strip.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.
+ // Only what this makes inert is given back, so a node held inert by
+ // something else stays so.
+ covered = [];
+ for (let node = pane; node !== panel; node = node.parentElement)
+ for (const sibling of node.parentElement.children)
+ if (sibling !== node && !sibling.inert) {
+ sibling.inert = true;
+ covered.push(sibling);
+ }
+ // A click focuses the button in Chromium but not in Safari or Firefox on
+ // macOS, so focus left in what is now covered moves to the button.
+ if (document.activeElement.closest("[inert]")) expand.focus();
+ }
+ function shrinkBoard() {
+ if (!root.hasAttribute("data-expanded")) return;
+ root.removeAttribute("data-expanded");
+ setToolIcon(expand, "expand", "Expand drawing");
+ for (const sibling of covered) sibling.inert = false;
+ // The panel and the strip go back where the candidate had them, once the
+ // drawer's observer has measured the docked height again after this
+ // returns; restored before it, the strip is clamped against its old
+ // height after a resize while expanded.
+ const [top, left] = stripBack;
+ queueMicrotask(() => {
+ panel.scrollTop = scrollBack;
+ strip.scrollTop = top;
+ strip.scrollLeft = left;
+ });
+ }
+ if (expand) {
+ setToolIcon(expand, "expand", "Expand drawing");
+ expand.addEventListener("click", () => {
+ if (drag || textDraft.busy()) return;
+ if (root.hasAttribute("data-expanded")) shrinkBoard();
+ else expandBoard();
+ });
+ // 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, a held key or a handler that already
+ // took it. Under a dialog the board is inert, so a key from Jim's stage,
+ // which the ending wait lifts over that dialog, is not the board's; with
+ // no dialog the stage's card is like the sidebar. The Escape that shrinks
+ // the board is marked as taken, so a later listener does not act on it
+ // too; one while docked passes through untouched.
+ 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.closest("[inert]") ||
+ event.defaultPrevented ||
+ event.target.closest?.("input, textarea, select, dialog, [role=dialog]")
+ )
+ return;
+ if (!root.hasAttribute("data-expanded")) return;
+ event.preventDefault();
+ shrinkBoard();
+ });
+ }
root.addEventListener("toggle", () => {
- if (!root.open) cancelTurn?.();
- if (!root.open && drag) {
- model.cancel();
- drag = null;
- if (previewShared) dirty = true;
- previewShared = false;
+ // Nothing on the page closes it while expanded, but whatever does must
+ // not leave the editor inert under a board that is gone. Everything a
+ // close undoes is here: the board shrinks, then the turn and any stroke
+ // are cancelled.
+ if (!root.open) {
+ shrinkBoard();
+ cancelTurn?.();
+ if (drag) {
+ model.cancel();
+ drag = null;
+ if (previewShared) dirty = true;
+ previewShared = false;
+ }
}
paint();
if (dirty && !drag) schedule();
@@ -285,6 +394,7 @@ export function mountExampleBoard(
paint();
return {
refresh: paint,
+ expanded: () => root.hasAttribute("data-expanded"),
async flush() {
const deadline = Date.now() + 2000;
while (uploading && Date.now() < deadline)
diff --git a/web/interview-drawer.js b/web/interview-drawer.js
index 6031e85e..ad95ab0d 100644
--- a/web/interview-drawer.js
+++ b/web/interview-drawer.js
@@ -45,7 +45,9 @@ export function mountInterviewDrawer(panel) {
"aria-valuenow",
String(Math.round(sized ? height : drawer.clientHeight)),
);
- if (example.open) {
+ // An expanded board sizes itself over the panel; its docked height is
+ // measured again when it shrinks, not from the box it floats in.
+ if (example.open && !example.hasAttribute("data-expanded")) {
// Reserve room for summaries, tools and results; the drawing scrolls
// inside its viewport without changing the model's coordinates.
const contentHeight = [...drawer.children].reduce((total, node) => {
@@ -107,6 +109,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.
+
-
- Sketch an example while explaining it. Jim sees it after you pause
- drawing.
-