perfect-postcode/video/src/dom.ts
2026-06-22 22:04:20 +01:00

1030 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
style: 'arrow' | 'touch' = 'arrow'
): 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; }
/* Touch mode: a soft fingertip dot centred on the contact point. */
#__demo-cursor.touch {
width: 34px; height: 34px;
transform-origin: 17px 17px;
}
#__demo-cursor.touch.click { scale: 0.7; }
.__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 = short HOOK CHIP, not a transcript. Cues carry an optional
* ≤6-word caption that complements the narration (the spoken line is
* never rendered). Because the text is short, the chip can be loud
* (big type, accent bar) without covering the product.
*
* 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 use a compact lower-third chip 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%, 24px);
width: max-content;
max-width: min(900px, 88vw);
padding: 14px 22px;
border-radius: 14px;
background: rgba(2, 6, 23, 0.9);
backdrop-filter: blur(16px) saturate(1.1);
-webkit-backdrop-filter: blur(16px) saturate(1.1);
color: #ffffff;
font:
850 34px/1.15 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
sans-serif;
letter-spacing: -0.01em;
text-align: center;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
box-shadow:
0 16px 44px rgba(0, 0, 0, 0.5),
inset 0 0 0 1.5px rgba(45, 212, 191, 0.45);
z-index: 2147483641;
opacity: 0;
pointer-events: none;
transition:
opacity 240ms ease-out,
transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: normal;
}
/* Horizontal: compact lower-third chip. */
body.__demo-aspect-horizontal #__demo-caption {
bottom: 6%;
}
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: 14px 20px;
border-radius: 14px;
font-size: 26px;
line-height: 1.18;
text-align: left;
}
/* Vertical: upper area, clear of platform chrome and the bottom sheet.
At the 540-wide CSS viewport this renders ~30px type → 60px in the
published 1080-wide mp4: a proper social-video hook size. */
body.__demo-aspect-vertical #__demo-caption {
top: 14%;
max-width: 88vw;
font-size: 30px;
font-weight: 850;
padding: 12px 18px;
border-radius: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
#__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-subtitle {
font: 500 22px/1.34 "Inter", system-ui, sans-serif;
color: #cbd5e1;
max-width: 30ch;
margin: 2px auto 4px;
text-align: center;
}
#__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-subtitle { font-size: 20px; max-width: 24ch; }
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((style) => {
const cursor = document.createElement('div');
cursor.id = '__demo-cursor';
// Hotspot: arrow tip sits 2px in from the SVG corner; the touch dot is
// centred on the contact point.
const hot = style === 'touch' ? 17 : 2;
if (style === 'touch') {
cursor.classList.add('touch');
cursor.innerHTML = `
<svg viewBox="0 0 34 34" width="34" height="34" xmlns="http://www.w3.org/2000/svg">
<circle cx="17" cy="17" r="14" fill="rgba(255,255,255,0.38)"
stroke="rgba(255,255,255,0.95)" stroke-width="2.5"/>
</svg>`;
} else {
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 - hot}px, ${e.clientY - hot}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 - hot}px, ${y - hot}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 }
);
}, style);
}
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,
subtitle?: string
): Promise<void> {
await page.evaluate(
({ brand, tagline, url, subtitle }) => {
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(/\/$/, '');
// Optional spoken-outro line, burned in for muted social viewers.
if (subtitle && subtitle.length > 0) {
const subtitleEl = document.createElement('div');
subtitleEl.id = '__demo-outro-subtitle';
subtitleEl.textContent = subtitle;
card.append(brandEl, taglineEl, subtitleEl, urlEl);
} else {
card.append(brandEl, taglineEl, urlEl);
}
el.appendChild(card);
document.body.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('visible'));
});
},
{ brand, tagline, url, subtitle }
);
}
/**
* 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 };
// Clamp the pan so the scaled app always covers the whole viewport.
// Without this, focusing an element near a screen edge drags the app
// off-frame and exposes the dark backdrop (the old cold-open showed a
// third of the frame as void). For scale ≥ 1 the translation must stay
// within [size·(1scale), 0] on each axis.
const clampPan = (value: number, span: number): number =>
scale >= 1 ? Math.min(0, Math.max(span * (1 - scale), value)) : value;
const dx = clampPan(viewport.width / 2 - scale * focusX, viewport.width);
const dy = clampPan(viewport.height / 2 - scale * focusY, viewport.height);
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
);
}