Files
DumiFlex d1f89d0e64 chore(tutorials): record video tutorials with a Watchwright pack (#264)
Adds the tutorials/ Watchwright pack (T01 "Your first random prompt"), Kokoro voice lines and render.mjs; bench resetLibrary checks the fixture's own module count.
2026-11-04 12:38:10 +01:00

140 lines
5.4 KiB
JavaScript

/* The tutorial overlay, added to every page before it loads (Playwright addInitScript).
* Plain JS: it runs in the page, not in the build.
*
* - A steady pointer that follows the mouse Playwright moves (real DOM events, so it follows
* clicks, hovers and canvas drags) and a ring where the button goes down. It never fades.
* - A caption bar at the bottom for the narration line.
* - A callout: a box around a part of the screen with a short label.
*
* Everything is pointer-events: none and sits above the app, so it never takes a click. */
(() => {
if (window.__tut) return;
const Z = "2147483647";
const ARROW =
'<svg width="26" height="32" viewBox="0 0 26 32" xmlns="http://www.w3.org/2000/svg">' +
'<path d="M2 2 L2 26 L8.5 20 L13 30 L17.5 28 L13 18.5 L22 18.5 Z" fill="#fff" stroke="#111" stroke-width="1.8" stroke-linejoin="round"/></svg>';
let root = null;
let pointer = null;
let caption = null;
const callouts = new Map();
let x = -100;
let y = -100;
function layer() {
if (root && root.isConnected) return root;
if (!document.body) return null;
root = document.createElement("div");
root.setAttribute("data-tut", "layer");
root.style.cssText = `position:fixed;inset:0;pointer-events:none;z-index:${Z};`;
pointer = document.createElement("div");
pointer.style.cssText = "position:absolute;left:0;top:0;width:26px;height:32px;transform:translate(-100px,-100px);filter:drop-shadow(0 2px 3px rgba(0,0,0,.45));will-change:transform;";
pointer.innerHTML = ARROW;
caption = document.createElement("div");
caption.style.cssText =
"position:absolute;left:50%;bottom:44px;transform:translateX(-50%);max-width:min(1400px,calc(100% - 120px));" +
"padding:14px 26px;border-radius:12px;background:rgba(12,12,16,.86);color:#fff;" +
"font:600 30px/1.35 'Inter','Segoe UI',system-ui,sans-serif;text-align:center;letter-spacing:.005em;" +
"box-shadow:0 6px 24px rgba(0,0,0,.4);opacity:0;transition:opacity .25s ease;";
root.append(caption, pointer);
document.body.appendChild(root);
place();
return root;
}
function place() {
if (pointer) pointer.style.transform = `translate(${x - 2}px,${y - 2}px)`;
}
function ring(px, py) {
const r = layer();
if (!r) return;
const el = document.createElement("div");
el.style.cssText =
`position:absolute;left:${px - 18}px;top:${py - 18}px;width:36px;height:36px;border-radius:50%;` +
"border:3px solid rgba(124,108,255,.95);background:rgba(124,108,255,.22);transform:scale(.4);opacity:1;" +
"transition:transform .35s ease-out,opacity .45s ease-out;";
r.insertBefore(el, pointer);
requestAnimationFrame(() => {
el.style.transform = "scale(1.25)";
el.style.opacity = "0";
});
setTimeout(() => el.remove(), 600);
}
const move = (e) => {
x = e.clientX;
y = e.clientY;
layer();
place();
};
// Capture phase on window: the canvas and dialogs stop some events from bubbling.
window.addEventListener("pointermove", move, true);
window.addEventListener("mousemove", move, true);
window.addEventListener("pointerdown", (e) => {
move(e);
ring(e.clientX, e.clientY);
}, true);
// A page that rebuilds its body (a route change) drops the layer: put it back.
setInterval(() => {
if (root && !root.isConnected && document.body) document.body.appendChild(root);
}, 400);
window.__tut = {
/** A magenta square in the top-right corner for a moment: render.mjs finds it to line the voice up with the video. */
mark(ms) {
const r = layer();
if (!r) return;
const m = document.createElement("div");
m.style.cssText = "position:absolute;right:0;top:0;width:16px;height:16px;background:#ff00ff;";
r.appendChild(m);
setTimeout(() => m.remove(), ms);
},
caption(text) {
layer();
if (!caption) return;
if (!text) {
caption.style.opacity = "0";
return;
}
caption.textContent = text;
caption.style.opacity = "1";
},
callout(id, rect, label) {
const r = layer();
if (!r) return;
this.uncallout(id);
const box = document.createElement("div");
const pad = 8;
box.style.cssText =
`position:absolute;left:${rect.x - pad}px;top:${rect.y - pad}px;width:${rect.width + pad * 2}px;height:${rect.height + pad * 2}px;` +
"border:3px solid #7c6cff;border-radius:12px;box-shadow:0 0 0 9999px rgba(0,0,0,.28),0 0 18px rgba(124,108,255,.7);" +
"opacity:0;transition:opacity .3s ease;";
if (label) {
const tag = document.createElement("div");
tag.textContent = label;
const above = rect.y > 70;
tag.style.cssText =
`position:absolute;left:-3px;${above ? "bottom:calc(100% + 8px)" : "top:calc(100% + 8px)"};white-space:nowrap;` +
"padding:6px 14px;border-radius:8px;background:#7c6cff;color:#fff;font:600 22px/1.3 'Inter','Segoe UI',system-ui,sans-serif;";
box.appendChild(tag);
}
r.insertBefore(box, caption);
requestAnimationFrame(() => (box.style.opacity = "1"));
callouts.set(id, box);
},
uncallout(id) {
const old = callouts.get(id);
if (!old) return;
callouts.delete(id);
old.style.opacity = "0";
setTimeout(() => old.remove(), 320);
},
clear() {
for (const id of [...callouts.keys()]) this.uncallout(id);
this.caption("");
},
};
document.addEventListener("DOMContentLoaded", () => layer());
})();