1030 lines
36 KiB
TypeScript
1030 lines
36 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,
|
||
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·(1−scale), 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
|
||
);
|
||
}
|