feat(learner): cross-fade deck slides

The deck viewer draws the next slide into a second canvas and fades it in over the current one, 4 px from the
direction of travel; the stage never blanks while pdf.js draws. A newer draw into a canvas cancels the older one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:50:54 +01:00
co-authored by Claude Opus 5.5
parent a83ac02a90
commit d5e836cc79
3 changed files with 105 additions and 23 deletions
+35
View File
@@ -189,4 +189,39 @@ run("navigation motion (headless Chrome)", () => {
expect(closing.some((h) => h > 1 && h < full - 1)).toBe(true);
expect(closing.at(-1)).toBe(0);
}, 30_000);
test("slides cross-fade in the direction of travel and the stage never blanks", async () => {
await open("/c/sample/m1-video", ".stage canvas[data-front]");
await until(() => chrome.eval<boolean>(`document.querySelector(".deckbar .count").textContent.trim() === "1 / 3"`), "deck loaded");
// Next slide; then sample every frame: the animations running and the most opaque canvas.
const frames = await chrome.eval<{ anims: string[]; maxOpacity: number }[]>(`(() => {
document.querySelector('.deckbar [aria-label="Next slide"]').click();
const out = [], t0 = performance.now();
return new Promise((r) => { const f = () => {
const cs = [...document.querySelectorAll(".stage canvas")];
out.push({ anims: document.getAnimations().map((a) => a.animationName).filter(Boolean), maxOpacity: Math.max(...cs.map((c) => Number(getComputedStyle(c).opacity))) });
if (performance.now() - t0 < 600) requestAnimationFrame(f); else r(out);
}; f(); });
})()`);
const names = new Set(frames.flatMap((f) => f.anims));
expect(names.has("stc-slide-next")).toBe(true);
expect(names.has("stc-hold-out")).toBe(true);
expect(frames.every((f) => f.maxOpacity === 1)).toBe(true);
expect(await text(".deckbar .count")).toBe("2 / 3");
expect(await chrome.eval<number>(`document.querySelectorAll('.stage canvas[role="img"]').length`)).toBe(1);
expect(await chrome.eval<string>(`document.querySelector('.stage canvas[data-front]').getAttribute("aria-label")`)).toBe("Slide 2 of 3");
// The arrow keys still turn slides, backwards with the mirrored motion.
await reset();
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 });
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 });
await until(async () => (await text(".deckbar .count")) === "1 / 3", "previous slide");
await Bun.sleep(300);
expect((await m()).anims.some((a) => a.startsWith("stc-slide-prev@"))).toBe(true);
// Two quick clicks: the second supersedes the first draw and the stage ends on the last slide.
await chrome.eval(`(() => { const b = document.querySelector('.deckbar [aria-label="Next slide"]'); b.click(); setTimeout(() => b.click(), 5); return true; })()`);
await until(() => chrome.eval<boolean>(`document.querySelector(".stage canvas[data-front]").dataset.page === "3"`), "slide 3 drawn");
expect(await text(".deckbar .count")).toBe("3 / 3");
}, 30_000);
});