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 { 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 = ` `; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { await page.evaluate(() => { document.getElementById('__ad-scene')?.classList.remove('visible'); }); } export async function showOutro( page: Page, brand: string, tagline: string, url: string ): Promise { 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 : 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 { 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 { 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 { 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 { 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('*')).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 { await page.evaluate( (frameCount) => new Promise((resolve) => { let seen = 0; const tick = () => { seen += 1; if (seen >= frameCount) resolve(); else requestAnimationFrame(tick); }; requestAnimationFrame(tick); }), frames ); }