/* 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 = '' + ''; let root = null; let pointer = null; let caption = null; const callouts = new Map(); /** * Puts a callout's box around `rect`, kept a few px inside the window so its stroke is never cut * off at an edge, and its label (above, or below near the top) shifted in when it would run off. */ const EDGE = 6; function placeCallout(box, rect) { const vw = window.innerWidth; const vh = window.innerHeight; // Padding is the same on both sides of an axis: near an edge (a top-bar button) it shrinks on // both, so the box stays centred on its part instead of losing only the side at the edge. const room = (lo, hi, max) => Math.max(2, Math.min(8, lo - EDGE, max - EDGE - hi)); // And the same on every side: a top-bar button only has a few pixels above it, and a box with // 8 px at the sides but 2 px above and below read as off-centre (wide and squat round a square // button), so the tighter axis sets both. const pad = Math.min(room(rect.x, rect.x + rect.width, vw), room(rect.y, rect.y + rect.height, vh)); const padX = pad; const padY = pad; // Clamped to the window, a side can still lose padding (a top-bar button sits closer to the // top than EDGE): take the same off the opposite side, so the box stays centred on the part. const cut = (lo, hi, p, max) => Math.min(p, lo - EDGE, max - EDGE - hi); // Still the same on every side, for the reason above. const px = Math.min(cut(rect.x, rect.x + rect.width, padX, vw), cut(rect.y, rect.y + rect.height, padY, vh)); const py = px; const left = rect.x - px; const top = rect.y - py; const right = rect.x + rect.width + px; const bottom = rect.y + rect.height + py; box.style.left = `${left}px`; box.style.top = `${top}px`; box.style.width = `${Math.max(0, right - left)}px`; box.style.height = `${Math.max(0, bottom - top)}px`; const tag = box.firstElementChild; if (!tag) return; const tw = tag.offsetWidth; const th = tag.offsetHeight; // Above the box when there is room, else below it, else inside its top edge. if (top - th - 8 >= EDGE) { tag.style.top = ""; tag.style.bottom = "calc(100% + 8px)"; } else if (bottom + th + 8 <= vh - EDGE) { tag.style.bottom = ""; tag.style.top = "calc(100% + 8px)"; } else { tag.style.bottom = ""; tag.style.top = "8px"; } // From the box's left edge, moved left as far as it must to stay inside the window. const x = Math.min(-3, vw - EDGE - tw - left); tag.style.left = `${Math.max(EDGE - left, x)}px`; } let x = -100; let y = -100; /** * The caption sits at the bottom, unless a callout reaches down into its band (a canvas node or a * dialog's footer that can't be scrolled up) and none sits at the top: then it moves up there. */ const BAND = 200; function placeCaption() { if (!caption) return; const vh = window.innerHeight; let low = false; let high = false; for (const box of callouts.values()) { const top = parseFloat(box.style.top) || 0; const bottom = top + (parseFloat(box.style.height) || 0); if (bottom > vh - BAND) low = true; if (top < BAND) high = true; } // An open list or menu (a mode dropdown, a picker) counts too: the viewer is reading it. for (const el of document.querySelectorAll('[role="listbox"], [role="menu"], .p-select-overlay, .p-autocomplete-overlay')) { const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4 || getComputedStyle(el).visibility === "hidden") continue; if (r.bottom > vh - BAND) low = true; if (r.top < BAND) high = true; } if (low && !high) { caption.style.bottom = ""; caption.style.top = "72px"; } else { caption.style.top = ""; caption.style.bottom = "44px"; } } 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:20px;height:25px;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 - 14}px;top:${py - 14}px;width:28px;height:28px;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(() => requestAnimationFrame(() => 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"); box.style.cssText = "position:absolute;box-sizing:border-box;" + "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; tag.style.cssText = "position:absolute;left:-3px;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); placeCallout(box, rect); requestAnimationFrame(() => (box.style.opacity = "1")); callouts.set(id, box); placeCaption(); }, /** Moves a callout to a new rect (its part moved or changed size). */ recallout(id, rect) { const box = callouts.get(id); if (!box) return; placeCallout(box, rect); placeCaption(); }, uncallout(id) { const old = callouts.get(id); if (!old) return; callouts.delete(id); placeCaption(); old.style.opacity = "0"; setTimeout(() => old.remove(), 320); }, clear() { for (const id of [...callouts.keys()]) this.uncallout(id); this.caption(""); }, }; document.addEventListener("DOMContentLoaded", () => layer()); // Lists open and close without a callout changing, so the caption checks its place on a timer. setInterval(() => { if (caption && caption.textContent) placeCaption(); }, 250); })();