975 lines
34 KiB
TypeScript
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
|
|
);
|
|
}
|