import type { Page } from 'playwright'; import type { DashboardRecorder, HexagonClickTarget } from './dashboard.js'; import { AI_ZOOM_SCALE, BRAND_NAME, BRAND_TAGLINE, BRAND_URL, PROMPT_TEXT, TT_CARD_SELECTOR, TT_DRAG_FROM_MIN, TT_DRAG_TO_MIN, TT_SLIDER_MAX, } from './config.js'; import { clearVignette, flashRect, hideCaption, showCaption, showOutro, zoomReset, zoomTo, } from './dom.js'; import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js'; export interface SceneCtx { page: Page; dashboard: DashboardRecorder; cursor: { x: number; y: number }; } /** * Scene 1: start wide, then zoom into the AI prompt. The AI response is * stubbed, while the map filters and right pane are loaded from the real app. */ export async function sceneAiCloseUp(ctx: SceneCtx): Promise { const { page, dashboard } = ctx; await clearVignette(page); await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.'); await sleep(180); await zoomToAiBox(page, 720); await sleep(760); await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 18); await sleep(160); const aiResponse = page .waitForResponse( (response) => response.url().includes('/api/ai-filters') && response.status() === 200, { timeout: 1800 } ) .catch(() => null); const mapVersion = dashboard.getMapDataVersion(); await page.evaluate(() => { document.querySelector('[data-tutorial="ai-filters"] form')?.requestSubmit(); }); await aiResponse; await sleep(160); await dashboard.waitForMapSettled(mapVersion, 15000); await showCaption(page, 'The filters are already live on the map.'); await sleep(560); await hideCaption(page); } /** * Scene 2: animate the wrapper back to scale 1 so the full dashboard is * revealed. The map has already pan-flown to Manchester (MapPage's * own flyTo fires when AI travel-time filters are applied), so the zoom-out * lands on a useful, filtered view. */ export async function sceneZoomOutResults(ctx: SceneCtx): Promise { const { page } = ctx; await showCaption(page, 'Now the view lands on central Manchester with the filters already set.'); await zoomReset(page, 860); await sleep(980); await hideCaption(page); await sleep(180); } /** * Scene 3: drag the right thumb of the AI-applied travel-time slider from * 35 to 20 minutes. The slider has step=1 over 0–120, so the 15-minute * range crosses 15 step boundaries — at our pace each one gets ~20+ recorded * frames, so the thumb reads as a continuous slide rather than incremental. * * The card we drag (`tt_0`) only exists because the AI filter step inserted * exactly one travel-time entry; if you change the AI stub's count, update * the selector or this scene will time out. */ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise { const { page, dashboard } = ctx; await showCaption( page, `Then tighten the commute: ${TT_DRAG_FROM_MIN} minutes down to ${TT_DRAG_TO_MIN}.` ); const card = page.locator(TT_CARD_SELECTOR); await card.waitFor({ state: 'visible', timeout: 4000 }); await card.scrollIntoViewIfNeeded(); await sleep(60); // Two thumbs in a Radix range slider; the second one is the max. const thumbSelector = `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`; // Track is the first horizontal-orientation element inside the card. const trackSelector = `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`; // Slider goes 0..120, target = 20 → fraction 0.166... const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX; const mapVersion = dashboard.getMapDataVersion(); ctx.cursor = await smoothDragSliderThumb( page, thumbSelector, trackSelector, ctx.cursor, toFraction, 1180 ); await sleep(220); await dashboard.waitForMapSettled(mapVersion, 16000); await showCaption(page, 'The map redraws around the areas that still work.'); await sleep(720); await hideCaption(page); await sleep(180); } /** * Scene 4: after the filtered result map is visible, zoom into Manchester, * click a hexagon, then let the right pane open from that selection. */ export async function sceneClusterClick(ctx: SceneCtx): Promise { const { page } = ctx; const viewport = page.viewportSize() ?? { width: 1920, height: 1080 }; await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.'); const cluster = { x: 360 + (viewport.width - 360) * 0.35, y: viewport.height * 0.52, }; await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 }); ctx.cursor = cluster; await sleep(220); await zoomMapWithWheel(ctx, cluster); ctx.cursor = await clickVisibleHexagon(ctx); await sleep(360); await showCaption( page, 'This is the useful pause: local stats, matching homes, and street context together.' ); await sleep(1000); await hideCaption(page); } async function zoomMapWithWheel(ctx: SceneCtx, target: { x: number; y: number }): Promise { const { page, dashboard } = ctx; const mapVersion = dashboard.getMapDataVersion(); await page.mouse.move(target.x, target.y); for (let i = 0; i < 5; i++) { await page.mouse.wheel(0, -120); await sleep(95); } await dashboard.waitForMapSettled(mapVersion, 16000); await sleep(260); } async function clickVisibleHexagon(ctx: SceneCtx): Promise<{ x: number; y: number }> { const candidates = await ctx.dashboard.visibleHexagonTargets(8); const startedAt = ctx.dashboard.getSelectionStatsVersion(); let lastError: Error | null = null; for (const target of candidates) { await moveAndClickHexagon(ctx, target); try { await ctx.dashboard.waitForSelectionReady(startedAt, 7000); return { x: target.x, y: target.y }; } catch (error) { if (ctx.dashboard.getSelectionStatsVersion() > startedAt) { return { x: target.x, y: target.y }; } lastError = error instanceof Error ? error : new Error(String(error)); } } throw new Error( `Could not open a map selection from the visible hexagons${ lastError ? `: ${lastError.message}` : '' }` ); } async function moveAndClickHexagon(ctx: SceneCtx, target: HexagonClickTarget): Promise { await smoothMove(ctx.page, ctx.cursor, { x: target.x, y: target.y }, { durationMs: 420 }); ctx.cursor = { x: target.x, y: target.y }; await ctx.page.mouse.click(target.x, target.y); await sleep(140); } /** Export the current shortlist, then reveal the URL. */ export async function sceneExportAndOutro(ctx: SceneCtx): Promise { const { page } = ctx; await showCaption(page, 'When the shortlist feels right, export the exact filtered view.'); await zoomReset(page, 680); await sleep(520); const exportButton = page.locator('button[title="Export to Excel"]').first(); await exportButton.waitFor({ state: 'visible', timeout: 4000 }); const box = await exportButton.boundingBox(); if (!box) throw new Error('Export button has no bounding box'); const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; await smoothMove(page, ctx.cursor, target, { durationMs: 620 }); ctx.cursor = target; await sleep(160); const download = page.waitForEvent('download', { timeout: 4000 }).catch(() => null); await page.mouse.click(target.x, target.y); await flashRect(page, box); await sleep(680); await hideCaption(page); await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL); void download; await sleep(2200); } /** Open the AI prompt before the timed scene starts. */ export async function prepareAiBox(ctx: SceneCtx): Promise { const { page } = ctx; const aiRoot = page.locator('[data-tutorial="ai-filters"]').first(); await aiRoot.waitFor({ state: 'visible', timeout: 15000 }); const textarea = page.locator('[data-tutorial="ai-filters"] textarea'); if (!(await textarea.isVisible().catch(() => false))) { const aiButton = aiRoot.locator('button').first(); await aiButton.waitFor({ state: 'visible', timeout: 8000 }); const btnBox = await aiButton.boundingBox(); if (btnBox) await page.mouse.click(btnBox.x + btnBox.width / 2, btnBox.y + btnBox.height / 2); } if (!(await textarea.isVisible().catch(() => false))) { await page.evaluate(() => { document.querySelector('[data-tutorial="ai-filters"] button')?.click(); }); } await textarea.waitFor({ state: 'visible', timeout: 15000 }); await sleep(100); } async function zoomToAiBox(page: Page, durationMs: number): Promise { const aiCard = page.locator('[data-tutorial="ai-filters"]'); const cardBox = await aiCard.boundingBox(); if (!cardBox) throw new Error('AI card has no bounding box'); const focusX = cardBox.x + cardBox.width / 2; const focusY = cardBox.y + cardBox.height / 2; await zoomTo(page, { scale: AI_ZOOM_SCALE, focusX, focusY, durationMs }); }