perfect-postcode/video/src/dom.ts
2026-05-26 19:45:13 +01:00

975 lines
34 KiB
TypeScript

import type { Page } from 'playwright';
import type { AdScene, AdScenePanel } from './script.js';
/**
* Inject a visible cursor that mirrors the real mouse position. The browser's
* native cursor is hidden so what the viewer sees is entirely our element.
*
* Design choice: the cursor listens to mousemove rather than being driven from
* the Node side. That keeps a single source of truth — Playwright's real mouse
* — and the visual is pure CSS, animated by the browser's compositor.
*/
export async function installCursor(page: Page): Promise<void> {
await page.addStyleTag({
content: `
*, *::before, *::after { cursor: none !important; }
#__demo-cursor {
position: fixed;
top: 0; left: 0;
width: 22px; height: 22px;
pointer-events: none;
z-index: 2147483646;
transform: translate(-2px, -2px);
transform-origin: 2px 2px;
transition: transform 60ms linear, scale 120ms ease-out;
will-change: transform, scale;
scale: 1;
}
#__demo-cursor svg {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35));
}
#__demo-cursor.click { scale: 0.85; }
.__demo-ripple {
position: fixed;
pointer-events: none;
z-index: 2147483645;
width: 0; height: 0;
border-radius: 50%;
border: 2px solid rgba(20, 184, 166, 0.9);
background: rgba(20, 184, 166, 0.18);
transform: translate(-50%, -50%);
animation: __demo-ripple 600ms ease-out forwards;
}
@keyframes __demo-ripple {
0% { width: 0; height: 0; opacity: 1; }
100% { width: 64px; height: 64px; opacity: 0; }
}
.__demo-focus-pulse {
position: fixed;
pointer-events: none;
z-index: 2147483644;
border: 2px solid rgba(94, 234, 212, 0.95);
border-radius: 10px;
box-shadow: 0 0 0 0 rgba(20, 184, 166, 0.45), 0 18px 44px rgba(15, 23, 42, 0.35);
animation: __demo-focus-pulse 900ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes __demo-focus-pulse {
0% { opacity: 0; transform: scale(0.92); box-shadow: 0 0 0 0 rgba(20, 184, 166, 0.45); }
20% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.15); box-shadow: 0 0 0 22px rgba(20, 184, 166, 0); }
}
#__demo-vignette {
position: fixed; inset: 0;
pointer-events: none;
background: radial-gradient(circle at center, transparent 40%, rgba(0,0,0,0.55) 100%);
z-index: 2147483640;
opacity: 1;
transition: opacity 1000ms ease-out;
}
#__demo-vignette.gone { opacity: 0; }
/*
* Caption positioning rules of thumb:
* Vertical (9:16) cuts MUST keep the caption inside the top ~62% of
* the viewport. TikTok, Reels, and Shorts overlay their own chrome
* across the bottom ~30%, so anything below y=68% gets eaten by
* the platform UI. Mobile dashboard captures also have a sheet
* covering the bottom half, so a low caption sits over filter
* controls rather than over the map.
* Horizontal (16:9) cuts can use the classic lower-third instead.
* The body class is set once at recorder setup (setAspectClass) so
* every cue inherits the right positioning.
*/
#__demo-caption {
position: fixed;
left: 50%;
transform: translate(-50%, 28px);
width: max-content;
max-width: min(1160px, 86vw);
padding: 22px 30px;
border-radius: 22px;
background: rgba(2, 6, 23, 0.92);
backdrop-filter: blur(20px) saturate(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.1);
color: #ffffff;
font:
800 36px/1.22 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
sans-serif;
letter-spacing: -0.005em;
text-align: center;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
box-shadow:
0 22px 60px rgba(0, 0, 0, 0.55),
inset 0 0 0 1.5px rgba(255, 255, 255, 0.16);
z-index: 2147483641;
opacity: 0;
pointer-events: none;
transition:
opacity 280ms ease-out,
transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: normal;
}
/* Horizontal default: classic lower-third. */
body.__demo-aspect-horizontal #__demo-caption {
bottom: 7%;
}
body.__demo-aspect-horizontal #__demo-caption.placement-side {
left: auto;
right: 3.4%;
bottom: 10%;
transform: translate(28px, 0);
max-width: min(560px, 30vw);
padding: 18px 22px;
border-radius: 18px;
font-size: 26px;
line-height: 1.18;
text-align: left;
}
/* Vertical default: upper-third. Kept compact so the map remains the
primary visual in the social ad cuts. */
body.__demo-aspect-vertical #__demo-caption {
top: 7%;
max-width: min(820px, 82vw);
font-size: 27px;
font-weight: 750;
padding: 12px 18px;
border-radius: 14px;
}
#__demo-caption.visible {
opacity: 1;
transform: translate(-50%, 0);
}
body.__demo-aspect-horizontal #__demo-caption.placement-side.visible {
transform: translate(0, 0);
}
#__demo-outro {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(2, 6, 23, 0);
z-index: 2147483642;
pointer-events: none;
transition: background 700ms ease-out;
}
#__demo-outro.visible {
background:
radial-gradient(circle at 50% 38%, rgba(20, 184, 166, 0.28), transparent 34%),
rgba(2, 6, 23, 0.84);
backdrop-filter: blur(10px);
}
#__demo-outro-card {
text-align: center;
color: white;
opacity: 0;
transform: translateY(12px) scale(0.985);
position: relative;
z-index: 1;
display: block !important;
visibility: visible !important;
}
#__demo-outro.visible #__demo-outro-card {
animation: __demo-outro-pop 620ms cubic-bezier(0.22,1,0.36,1) both;
}
@keyframes __demo-outro-pop {
0% { opacity: 0; transform: translateY(12px) scale(0.985); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
#__demo-outro-brand {
font: 850 72px/1.05 "Inter", ui-sans-serif, system-ui, sans-serif;
margin: 0 0 18px;
background: linear-gradient(90deg, #5eead4, #14b8a6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: -0.015em;
}
#__demo-outro-tagline {
font: 600 28px/1.36 "Inter", ui-sans-serif, system-ui, sans-serif;
color: #e2e8f0;
margin: 0 0 30px;
max-width: 28ch;
margin-left: auto;
margin-right: auto;
}
#__demo-outro-url {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 18px 26px;
border-radius: 16px;
background: rgba(2, 6, 23, 0.92);
border: 1.5px solid rgba(45, 212, 191, 0.6);
box-shadow:
0 22px 60px rgba(0, 0, 0, 0.5),
0 0 36px rgba(45, 212, 191, 0.18);
font: 800 36px/1 "Inter", ui-sans-serif, system-ui, sans-serif;
letter-spacing: -0.01em;
color: #5eead4;
}
/* Tighter outro for vertical 9:16 — the brand/url stack must fit
comfortably inside the platform-safe centre column. */
body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; }
body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; }
body.__demo-aspect-vertical #__demo-outro-url { font-size: 30px; padding: 16px 22px; }
.__ad-scene {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 2147483639;
opacity: 0;
transition: opacity 260ms ease-out;
color: #f8fafc;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0;
}
.__ad-scene.visible { opacity: 1; }
.__ad-scene.accent-teal { --ad-accent: #2dd4bf; --ad-accent-soft: rgba(45, 212, 191, 0.22); }
.__ad-scene.accent-sky { --ad-accent: #38bdf8; --ad-accent-soft: rgba(56, 189, 248, 0.22); }
.__ad-scene.accent-amber { --ad-accent: #f59e0b; --ad-accent-soft: rgba(245, 158, 11, 0.24); }
.__ad-scene.accent-rose { --ad-accent: #fb7185; --ad-accent-soft: rgba(251, 113, 133, 0.22); }
.__ad-scene.accent-lime { --ad-accent: #a3e635; --ad-accent-soft: rgba(163, 230, 53, 0.18); }
.__ad-scene.accent-violet { --ad-accent: #a78bfa; --ad-accent-soft: rgba(167, 139, 250, 0.22); }
.__ad-scrim {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(2, 6, 23, 0.9) 0%, rgba(2, 6, 23, 0.66) 50%, rgba(2, 6, 23, 0.94) 100%),
linear-gradient(135deg, var(--ad-accent-soft), transparent 38%, rgba(15, 23, 42, 0.38));
backdrop-filter: blur(3px) saturate(0.92);
-webkit-backdrop-filter: blur(3px) saturate(0.92);
}
/*
* Transparent mode: no scrim, no blur — the product stays fully
* visible behind a floating kicker + title. Used by mid-cue hook
* stings ("Postcode polygraph") that should not occlude the demo.
*/
.__ad-scene.transparent .__ad-scrim { display: none; }
.__ad-scene.transparent .__ad-title,
.__ad-scene.transparent .__ad-body {
text-shadow:
0 3px 12px rgba(0, 0, 0, 0.75),
0 1px 3px rgba(0, 0, 0, 0.9);
}
.__ad-scene.transparent .__ad-frame {
background: linear-gradient(180deg, rgba(2, 6, 23, 0.78), rgba(2, 6, 23, 0.18) 60%, transparent);
padding-bottom: 60px;
bottom: auto;
height: 60%;
}
.__ad-frame {
position: absolute;
top: 6%;
left: 5%;
right: 5%;
bottom: 22%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 22px;
}
.__ad-kicker {
align-self: flex-start;
padding: 10px 14px;
border-radius: 8px;
color: #020617;
background: var(--ad-accent);
font: 800 28px/1 ui-sans-serif, system-ui, sans-serif;
text-transform: uppercase;
}
.__ad-comment {
max-width: 820px;
padding: 22px 24px;
border-radius: 8px;
background: rgba(248, 250, 252, 0.94);
color: #0f172a;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.34);
font: 700 34px/1.18 ui-sans-serif, system-ui, sans-serif;
}
.__ad-title {
margin: 0;
max-width: 940px;
color: #fff;
font: 850 64px/1.04 "Inter", ui-sans-serif, system-ui, sans-serif;
letter-spacing: -0.012em;
text-wrap: balance;
}
.__ad-body {
max-width: 890px;
margin: 0;
color: #e2e8f0;
font: 580 32px/1.26 "Inter", ui-sans-serif, system-ui, sans-serif;
text-wrap: balance;
}
.__ad-image {
width: 100%;
max-width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 18px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
border: 1.5px solid rgba(255, 255, 255, 0.1);
}
.__ad-image-caption {
margin-top: -6px;
color: #cbd5e1;
font: 600 22px/1.3 "Inter", ui-sans-serif, system-ui, sans-serif;
text-align: center;
}
.__ad-image-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.__ad-image-split .__ad-image {
aspect-ratio: 3 / 4;
}
.__ad-image-split-meta {
position: absolute;
left: 8px;
bottom: 8px;
right: 8px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(2, 6, 23, 0.86);
color: #f8fafc;
font: 700 22px/1.15 "Inter", ui-sans-serif, system-ui, sans-serif;
text-align: center;
}
.__ad-image-split-cell {
position: relative;
}
.__ad-image-split-cell.good .__ad-image-split-meta { color: #86efac; }
.__ad-image-split-cell.warn .__ad-image-split-meta { color: #fde68a; }
.__ad-image-split-cell.bad .__ad-image-split-meta { color: #fda4af; }
.__ad-split {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
margin-top: 4px;
}
.__ad-panel {
border-radius: 8px;
padding: 24px 26px;
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(226, 232, 240, 0.18);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.26);
}
.__ad-panel-title {
font: 800 42px/1.08 ui-sans-serif, system-ui, sans-serif;
color: #fff;
}
.__ad-panel-subtitle {
margin-top: 10px;
font: 570 28px/1.22 ui-sans-serif, system-ui, sans-serif;
color: #cbd5e1;
}
.__ad-panel-meta {
margin-top: 16px;
display: inline-flex;
padding: 8px 12px;
border-radius: 8px;
background: rgba(148, 163, 184, 0.18);
color: #e2e8f0;
font: 750 24px/1 ui-sans-serif, system-ui, sans-serif;
}
.__ad-items {
display: grid;
gap: 14px;
margin-top: 4px;
}
.__ad-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
min-height: 74px;
padding: 17px 20px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(226, 232, 240, 0.16);
box-shadow: 0 14px 42px rgba(0, 0, 0, 0.22);
}
.__ad-rank {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 8px;
color: #020617;
background: var(--ad-accent);
font: 850 24px/1 ui-sans-serif, system-ui, sans-serif;
}
.__ad-item-label {
color: #f8fafc;
font: 720 31px/1.13 ui-sans-serif, system-ui, sans-serif;
}
.__ad-item-value {
color: #bfdbfe;
font: 760 28px/1.1 ui-sans-serif, system-ui, sans-serif;
text-align: right;
}
.__ad-item.good { border-color: rgba(74, 222, 128, 0.45); }
.__ad-item.bad { border-color: rgba(251, 113, 133, 0.48); }
.__ad-item.warn { border-color: rgba(251, 191, 36, 0.48); }
.__ad-item.good .__ad-item-value { color: #86efac; }
.__ad-item.bad .__ad-item-value { color: #fda4af; }
.__ad-item.warn .__ad-item-value { color: #fde68a; }
.__ad-panel.good { border-color: rgba(74, 222, 128, 0.48); }
.__ad-panel.bad { border-color: rgba(251, 113, 133, 0.48); }
.__ad-panel.warn { border-color: rgba(251, 191, 36, 0.48); }
.__ad-footer {
margin-top: 6px;
color: #e0f2fe;
font: 760 31px/1.18 ui-sans-serif, system-ui, sans-serif;
}
.__ad-progress {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(226, 232, 240, 0.22);
}
.__ad-progress-fill {
height: 100%;
width: 0%;
background: var(--ad-accent);
box-shadow: 0 0 28px var(--ad-accent);
}
.__ad-scene.mode-title .__ad-frame {
justify-content: center;
}
.__ad-scene.mode-title .__ad-title {
font-size: 78px;
}
/*
* Mobile (9x16) renders the dashboard at CSS 540x960 with captureScale 2.
* That means the AdScene CSS is sizing against a 540px-wide viewport, so
* a 64px title is ~12% of viewport width per line — still big and bold,
* but no longer overflows the available space the way 82px did on the
* old 1080-wide ad config. Captions also re-anchor to the upper third
* via body.__demo-aspect-vertical.
*/
body.__demo-aspect-vertical .__ad-scene .__ad-title {
font-size: 58px;
}
body.__demo-aspect-vertical .__ad-scene.mode-title .__ad-title {
font-size: 68px;
}
body.__demo-aspect-vertical .__ad-scene .__ad-body {
font-size: 28px;
}
.__ad-scene.mode-comment .__ad-comment {
margin-bottom: 12px;
}
.__ad-scene.mode-tabs .__ad-items {
grid-template-columns: 1fr 1fr;
}
.__ad-scene.mode-tabs .__ad-item {
grid-template-columns: auto 1fr;
min-height: 86px;
}
.__ad-scene.mode-tabs .__ad-item-value {
display: none;
}
.__ad-scene.mode-scanner .__ad-frame::after,
.__ad-scene.mode-polygraph .__ad-frame::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 48%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--ad-accent), transparent);
box-shadow: 0 0 28px var(--ad-accent);
animation: __ad-scan 1.8s ease-in-out infinite;
}
@keyframes __ad-scan {
0% { transform: translateY(-180px); opacity: 0; }
18% { opacity: 1; }
82% { opacity: 1; }
100% { transform: translateY(180px); opacity: 0; }
}
`,
});
await page.evaluate(() => {
const cursor = document.createElement('div');
cursor.id = '__demo-cursor';
cursor.innerHTML = `
<svg viewBox="0 0 22 22" width="22" height="22" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2 L2 18 L7 13 L10 20 L13 19 L10 12 L17 12 Z"
fill="white" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/>
</svg>`;
document.body.appendChild(cursor);
const vignette = document.createElement('div');
vignette.id = '__demo-vignette';
document.body.appendChild(vignette);
const caption = document.createElement('div');
caption.id = '__demo-caption';
document.body.appendChild(caption);
window.addEventListener(
'mousemove',
(e) => {
cursor.style.transform = `translate(${e.clientX - 2}px, ${e.clientY - 2}px)`;
},
{ passive: true, capture: true }
);
(window as typeof window & {
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
}).__demoMoveCursor = (x, y, durationMs) => {
cursor.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1), scale 120ms ease-out`;
cursor.style.transform = `translate(${x - 2}px, ${y - 2}px)`;
window.setTimeout(() => {
cursor.style.transition = 'transform 60ms linear, scale 120ms ease-out';
}, durationMs + 40);
};
window.addEventListener(
'mousedown',
(e) => {
cursor.classList.add('click');
const r = document.createElement('div');
r.className = '__demo-ripple';
r.style.left = `${e.clientX}px`;
r.style.top = `${e.clientY}px`;
document.body.appendChild(r);
setTimeout(() => r.remove(), 650);
},
{ passive: true, capture: true }
);
window.addEventListener(
'mouseup',
() => cursor.classList.remove('click'),
{ passive: true, capture: true }
);
});
}
export async function clearVignette(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById('__demo-vignette')?.classList.add('gone');
});
}
/**
* Tag the document body with the aspect class the caption / overlay CSS keys
* off. Run once during recorder setup so every cue inherits the right
* positioning without per-call overrides. The body class is the cheapest
* stable signal — the storyboard's `video.aspect` knows the truth and we
* surface it once into the DOM.
*/
export async function setAspectClass(
page: Page,
aspect: '16x9' | '9x16'
): Promise<void> {
await page.evaluate((aspect) => {
document.body.classList.remove(
'__demo-aspect-horizontal',
'__demo-aspect-vertical'
);
document.body.classList.add(
aspect === '9x16' ? '__demo-aspect-vertical' : '__demo-aspect-horizontal'
);
}, aspect);
}
export async function showCaption(
page: Page,
text: string,
placement?: 'side'
): Promise<void> {
await page.evaluate(({ t, placement }) => {
const el = document.getElementById('__demo-caption');
if (!el) return;
el.textContent = t;
el.classList.remove('placement-side');
if (placement) el.classList.add(`placement-${placement}`);
el.classList.add('visible');
}, { t: text, placement });
}
/**
* Animate the visible cursor to a new CSS scale. The injected cursor element
* uses the `scale` shorthand (separate from `transform: translate(...)`),
* which means resizing it doesn't fight the per-frame translate updates from
* mousemove. The transition duration is set inline so each call decides its
* own pace.
*/
export async function setCursorScale(
page: Page,
scale: number,
durationMs: number
): Promise<void> {
await page.evaluate(
({ scale, durationMs }) => {
const cursor = document.getElementById('__demo-cursor');
if (!cursor) return;
cursor.style.transition =
`transform 60ms linear, scale ${Math.max(0, durationMs)}ms cubic-bezier(0.22, 1, 0.36, 1)`;
cursor.style.scale = String(scale);
},
{ scale, durationMs }
);
}
export async function hideCaption(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById('__demo-caption')?.classList.remove('visible');
});
}
export async function flashRect(
page: Page,
rect: { x: number; y: number; width: number; height: number }
): Promise<void> {
await page.evaluate((r) => {
const el = document.createElement('div');
el.className = '__demo-focus-pulse';
el.style.left = `${r.x - 6}px`;
el.style.top = `${r.y - 6}px`;
el.style.width = `${r.width + 12}px`;
el.style.height = `${r.height + 12}px`;
document.body.appendChild(el);
setTimeout(() => el.remove(), 950);
}, rect);
}
export async function visualClick(
page: Page,
point: { x: number; y: number },
rippleColor = 'rgba(20, 184, 166, 0.9)'
): Promise<void> {
await page.evaluate(
({ point, rippleColor }) => {
const cursor = document.getElementById('__demo-cursor');
cursor?.classList.add('click');
window.setTimeout(() => cursor?.classList.remove('click'), 140);
const r = document.createElement('div');
r.className = '__demo-ripple';
r.style.left = `${point.x}px`;
r.style.top = `${point.y}px`;
r.style.borderColor = rippleColor;
document.body.appendChild(r);
window.setTimeout(() => r.remove(), 650);
},
{ point, rippleColor }
);
}
export async function showAdScene(page: Page, scene: AdScene): Promise<void> {
await page.evaluate((s) => {
const mode = s.mode ?? 'stack';
const accent = s.accent ?? 'teal';
const make = (tag: string, className: string, text?: string): HTMLElement => {
const el = document.createElement(tag);
el.className = className;
if (text) el.textContent = text;
return el;
};
const root =
document.getElementById('__ad-scene') ?? document.createElement('div');
root.id = '__ad-scene';
root.className =
`__ad-scene mode-${mode} accent-${accent}` +
(s.transparent ? ' transparent' : '');
root.replaceChildren();
const scrim = make('div', '__ad-scrim');
const frame = make('div', '__ad-frame');
if (s.comment) frame.appendChild(make('div', '__ad-comment', s.comment));
if (s.kicker) frame.appendChild(make('div', '__ad-kicker', s.kicker));
// Side-by-side photos (used by ads like "two streets apart"). When the
// ad has split images, render them in place of the panel grid: the
// photos themselves are the comparison, not just text labels.
if (s.images) {
const split = make('div', '__ad-image-split');
const [leftSrc, rightSrc] = s.images;
const buildCell = (src: string, panel?: AdScenePanel): HTMLElement => {
const cell = make('div', `__ad-image-split-cell ${panel?.tone ?? 'neutral'}`);
const img = document.createElement('img');
img.className = '__ad-image';
// Skip crossOrigin so the request goes through as a vanilla
// image fetch and CORS headers on the Unsplash CDN are not
// required. We never read pixels back out — display only.
img.referrerPolicy = 'no-referrer';
img.onerror = () => img.remove();
img.src = src;
cell.appendChild(img);
if (panel?.title || panel?.meta) {
const meta = make(
'div',
'__ad-image-split-meta',
[panel?.title, panel?.meta].filter(Boolean).join(' · ')
);
cell.appendChild(meta);
}
return cell;
};
split.appendChild(buildCell(leftSrc, s.left));
split.appendChild(buildCell(rightSrc, s.right));
frame.appendChild(split);
} else if (s.image) {
const img = document.createElement('img');
img.className = '__ad-image';
img.referrerPolicy = 'no-referrer';
img.onerror = () => img.remove();
img.src = s.image;
frame.appendChild(img);
if (s.imageCaption) {
frame.appendChild(make('div', '__ad-image-caption', s.imageCaption));
}
}
frame.appendChild(make('h1', '__ad-title', s.title));
if (s.body) frame.appendChild(make('p', '__ad-body', s.body));
// Text-only side panels are only used when there are no photos.
if (!s.images && (s.left || s.right)) {
const split = make('div', '__ad-split');
for (const panel of [s.left, s.right]) {
if (!panel) continue;
const panelEl = make('div', `__ad-panel ${panel.tone ?? 'neutral'}`);
panelEl.appendChild(make('div', '__ad-panel-title', panel.title));
if (panel.subtitle) {
panelEl.appendChild(make('div', '__ad-panel-subtitle', panel.subtitle));
}
if (panel.meta) panelEl.appendChild(make('div', '__ad-panel-meta', panel.meta));
split.appendChild(panelEl);
}
frame.appendChild(split);
}
if (s.items?.length) {
const list = make('div', '__ad-items');
s.items.forEach((item, index) => {
const row = make('div', `__ad-item ${item.tone ?? 'neutral'}`);
row.appendChild(make('span', '__ad-rank', String(index + 1)));
row.appendChild(make('span', '__ad-item-label', item.label));
row.appendChild(make('span', '__ad-item-value', item.value ?? ''));
list.appendChild(row);
});
frame.appendChild(list);
}
if (typeof s.progress === 'number') {
const progress = make('div', '__ad-progress');
const fill = make('div', '__ad-progress-fill');
fill.style.width = `${Math.round(Math.max(0, Math.min(1, s.progress)) * 100)}%`;
progress.appendChild(fill);
frame.appendChild(progress);
}
if (s.footer) frame.appendChild(make('div', '__ad-footer', s.footer));
root.append(scrim, frame);
if (!root.parentElement) document.body.appendChild(root);
requestAnimationFrame(() => root.classList.add('visible'));
}, scene);
}
export async function hideAdScene(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById('__ad-scene')?.classList.remove('visible');
});
}
export async function showOutro(
page: Page,
brand: string,
tagline: string,
url: string
): Promise<void> {
await page.evaluate(
({ brand, tagline, url }) => {
document.getElementById('__demo-caption')?.classList.remove('visible');
const el = document.createElement('div');
el.id = '__demo-outro';
const card = document.createElement('div');
card.id = '__demo-outro-card';
const brandEl = document.createElement('div');
brandEl.id = '__demo-outro-brand';
brandEl.textContent = brand;
const taglineEl = document.createElement('div');
taglineEl.id = '__demo-outro-tagline';
taglineEl.textContent = tagline;
const urlEl = document.createElement('div');
urlEl.id = '__demo-outro-url';
// Drop the protocol so the CTA reads as a bare domain.
urlEl.textContent = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
card.append(brandEl, taglineEl, urlEl);
el.appendChild(card);
document.body.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('visible'));
});
},
{ brand, tagline, url }
);
}
/**
* Wrap #root in a transformable div so we can CSS-zoom the entire app
* without dragging the cursor/caption/outro overlays along with it.
*
* Why a wrapper and not <body>: a transformed ancestor establishes a new
* containing block for `position: fixed` descendants — meaning fixed
* overlays inside the transform get scaled too. By wrapping ONLY #root
* and leaving the overlays as siblings of the wrapper, the cursor stays
* at native size while the dashboard zooms behind it.
*/
export async function installZoomWrapper(page: Page): Promise<void> {
await page.addStyleTag({
content: `
html, body { background: #111827 !important; }
#__demo-backdrop {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(circle at 18% 16%, rgba(20, 184, 166, 0.32), transparent 26%),
radial-gradient(circle at 78% 20%, rgba(14, 165, 233, 0.2), transparent 24%),
linear-gradient(135deg, #0f172a 0%, #111827 46%, #1f2937 100%);
}
#__demo-zoom-wrap {
position: fixed; inset: 0;
z-index: 1;
transform-origin: 0 0;
transform: translate(0px, 0px) scale(1);
will-change: transform;
overflow: hidden;
background: #f8fafc;
box-shadow: 0 36px 110px rgba(0,0,0,0.36);
}
#__demo-zoom-wrap::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.16);
}
`,
});
await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return;
if (document.getElementById('__demo-zoom-wrap')) return;
const backdrop = document.createElement('div');
backdrop.id = '__demo-backdrop';
document.body.insertBefore(backdrop, document.body.firstChild);
const wrap = document.createElement('div');
wrap.id = '__demo-zoom-wrap';
root.parentElement?.insertBefore(wrap, root);
wrap.appendChild(root);
});
}
/**
* Zoom the wrapper so that (focusX, focusY) in original CSS pixels ends up
* at the centre of the viewport at the given scale.
*
* Math: we use transform-origin (0,0) so a point (x,y) maps to
* (k·x + dx, k·y + dy)
* To put (focusX, focusY) at (W/2, H/2) we set
* dx = W/2 - k·focusX, dy = H/2 - k·focusY.
* This avoids the awkward double-application you get with non-zero origins.
*
* The transition is set inline so callers can pick a per-call duration
* without restyling. After this call the wrapper animates over `durationMs`;
* sleep that long to wait it out.
*/
export async function zoomTo(
page: Page,
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
): Promise<void> {
const { scale, focusX, focusY, durationMs = 1100 } = opts;
const transitionMs = Math.round(durationMs);
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const dx = viewport.width / 2 - scale * focusX;
const dy = viewport.height / 2 - scale * focusY;
await page.evaluate(
({ dx, dy, scale, transitionMs }) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transition = `transform ${transitionMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
},
{ dx, dy, scale, transitionMs }
);
}
/** Animate the wrapper back to identity transform. */
export async function zoomReset(page: Page, durationMs = 1100): Promise<void> {
const transitionMs = Math.round(durationMs);
await page.evaluate((transitionMs) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transition = `transform ${transitionMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transform = `translate(0px, 0px) scale(1)`;
}, transitionMs);
}
/**
* Smoothly scroll the closest scrollable ancestor of `selector` to `top`.
* Uses the browser's native smooth-scroll (compositor-driven, doesn't fight
* the recorder for CPU). If nothing scrollable is found, no-ops.
*/
export async function scrollPaneTo(
page: Page,
selector: string,
top: number
): Promise<void> {
await page.evaluate(
({ selector, top }) => {
const el = document.querySelector(selector) as HTMLElement | null;
if (!el) return;
const findScrollable = (node: HTMLElement | null): HTMLElement | null => {
let n: HTMLElement | null = node;
while (n) {
const oy = getComputedStyle(n).overflowY;
if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight) return n;
n = n.parentElement;
}
return null;
};
// Look both inside (for the actual scroll container deeper in the tree)
// and outwards.
const inner =
Array.from(el.querySelectorAll<HTMLElement>('*')).find((n) => {
const oy = getComputedStyle(n).overflowY;
return (oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight;
}) ?? null;
const target = inner ?? findScrollable(el) ?? el;
target.scrollTo({ top, behavior: 'smooth' });
},
{ selector, top }
);
}
export async function waitForAnimationFrames(page: Page, frames = 3): Promise<void> {
await page.evaluate(
(frameCount) =>
new Promise<void>((resolve) => {
let seen = 0;
const tick = () => {
seen += 1;
if (seen >= frameCount) resolve();
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
frames
);
}