Add video

This commit is contained in:
Andras Schmelczer 2026-05-05 22:15:29 +01:00
parent 589de0c5ac
commit 7c36cbfdd4
18 changed files with 2292 additions and 333 deletions

View file

@ -1,14 +1,25 @@
import type { Page } from 'playwright';
import {
AI_ZOOM_SCALE,
BRAND_NAME,
BRAND_TAGLINE,
BRAND_URL,
PROMPT_TEXT,
DRAG_FILTER_NAME,
DRAG_TO_FRACTION,
TT_CARD_SELECTOR,
TT_DRAG_FROM_MIN,
TT_DRAG_TO_MIN,
TT_SLIDER_MAX,
} from './config.js';
import {
clearVignette,
flashRect,
hideCaption,
scrollPaneTo,
showCaption,
showOutro,
zoomReset,
zoomTo,
zoomToInstant,
} from './dom.js';
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
@ -17,109 +28,232 @@ export interface SceneCtx {
cursor: { x: number; y: number };
}
/** Cold open. Vignette fades; cursor parks at a "natural" rest position. */
export async function sceneColdOpen(ctx: SceneCtx): Promise<void> {
await clearVignette(ctx.page);
await ctx.page.mouse.move(ctx.cursor.x, ctx.cursor.y);
await sleep(1100);
}
/**
* AI prompt scene: click the collapsed AI box, type the prompt, submit,
* watch the (stubbed) response apply.
* Scene 1: open already zoomed in on the AI prompt card. Caption fades in,
* the user types their request, and the already-preloaded filters are revealed
* behind the zoomed wrapper. Keeping this beat visual avoids slow dev-server
* data refreshes eating the 15-second timeline.
*
* Pre-conditions (set up by record.ts before scene timer starts):
* - The AI box is already expanded (textarea visible, ready to focus).
* - The wrapper is already zoomed at AI_ZOOM_SCALE on the AI box centre.
* - The vignette is up.
*/
export async function sceneAiPrompt(ctx: SceneCtx): Promise<void> {
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Describe the area you want.');
await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(160);
const aiButton = page.locator('[data-tutorial="ai-filters"] button').first();
const btnBox = await aiButton.boundingBox();
if (!btnBox) throw new Error('AI button not found');
const target = { x: btnBox.x + btnBox.width / 2, y: btnBox.y + btnBox.height / 2 };
await smoothMove(page, ctx.cursor, target, { durationMs: 400 });
ctx.cursor = target;
await page.mouse.click(target.x, target.y);
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
await textarea.waitFor({ state: 'visible', timeout: 3000 });
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 14);
await sleep(120);
const taBox = await textarea.boundingBox();
if (taBox) {
const into = { x: taBox.x + 30, y: taBox.y + taBox.height / 2 };
await smoothMove(page, ctx.cursor, into, { durationMs: 220 });
ctx.cursor = into;
}
// fakeType runs the typing animation inside the browser to avoid CDP
// round-trip overhead per keystroke (which can quadruple total typing time).
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 35);
await sleep(180);
await page.keyboard.press('Enter');
await sleep(700);
const aiResponse = page
.waitForResponse(
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
{ timeout: 1800 }
)
.catch(() => null);
await page.evaluate(() => {
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
});
await aiResponse;
await showCaption(page, 'The filters are already live on the map.');
await sleep(360);
await hideCaption(page);
await sleep(150);
}
/**
* Slider scene: pan to a numeric filter's right thumb and drag it inward.
* The whole point: the user sees the map react in real time to a human action,
* driving home that AI sets a starting point but you stay in control.
* 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 sceneSliderControl(ctx: SceneCtx): Promise<void> {
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'You stay in control.');
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
await zoomReset(page, 560);
await sleep(420);
await hideCaption(page);
await sleep(80);
}
const card = page.locator(`[data-filter-name="${DRAG_FILTER_NAME}"]`);
await card.waitFor({ state: 'visible', timeout: 3000 });
/**
* Scene 3: drag the right thumb of the AI-applied travel-time slider from
* 35 to 20 minutes. The slider has step=1 over 0120, 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<void> {
const { page } = 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(120);
await sleep(60);
const thumbSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [role="slider"] >> nth=1`;
const trackSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [data-orientation="horizontal"] >> nth=0`;
// 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;
ctx.cursor = await smoothDragSliderThumb(
page,
thumbSelector,
trackSelector,
ctx.cursor,
DRAG_TO_FRACTION,
1100
toFraction,
520
);
await sleep(550);
await sleep(120);
await showCaption(page, 'The map redraws around the areas that still work.');
await sleep(440);
await hideCaption(page);
await sleep(150);
await sleep(60);
}
/** Property reveal: click a postcode on the map to open the side pane with charts. */
export async function scenePropertyReveal(ctx: SceneCtx): Promise<void> {
/**
* Scene 4: zoom into a cluster of filtered postcodes (using deck.gl's own
* camera, via wheel events), click one, and as the right pane fills, pan
* the framing rightward while scrolling the pane content.
*
* Why two zoom mechanisms across this scene:
* - Pre-click: native deck.gl wheel-zoom. CSS-transforming the wrapper
* changes `canvas.getBoundingClientRect()` (scaled rect) without changing
* `canvas.width`. deck.gl's hit-test uses the rect for screenbuffer
* mapping, returns a partial picked object, and React re-renders mid-paint
* leaving a null layer reference that crashes `MapboxLayer.render`.
* Native wheel-zoom recomputes deck.gl's camera in-place; layers stay coherent.
* - Post-click: CSS transform to pan the framing rightward. By this point
* the postcode is selected and layers are stable, so the transform is safe.
*/
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const target = {
x: 360 + (viewport.width - 360) * 0.55,
y: viewport.height * 0.5,
await showCaption(page, 'Open one promising area and check the detail before shortlisting.');
// Click point: roughly map centre. After AI flew the camera to Manchester, this
// sits in the densely-filtered city core where hexagons reliably cover any
// pixel. Earlier iterations wheel-zoomed first to "feel cinematic", but
// that crossed the hexagon→postcode layer-swap threshold mid-flight and
// clicks landed in a layer gap (no pane opened).
const cluster = {
x: 360 + (viewport.width - 360) * 0.5,
y: viewport.height * 0.45,
};
await smoothMove(page, ctx.cursor, target, { durationMs: 500 });
ctx.cursor = target;
await smoothMove(page, ctx.cursor, cluster, { durationMs: 260 });
ctx.cursor = cluster;
await sleep(70);
await page.mouse.click(target.x, target.y);
await sleep(1300);
}
// The right pane was opened at page load via ?pc= — no need to drive a
// real selection through deck.gl's hit-test, which is flaky in headless
// Chromium. The mouse.click here is purely for the visible cursor ripple
// animation; the pane is already populated with real postcode data.
await page.mouse.click(cluster.x, cluster.y);
await sleep(130);
/** Outro: full-screen logo card with brand + URL. */
export async function sceneOutro(ctx: SceneCtx): Promise<void> {
await showOutro(
ctx.page,
'Perfect Postcodes',
'Find where you actually want to live.',
'perfectpostcodes.com'
// NOW zoom in toward the cluster, pan rightward to centre the right pane,
// and scroll the pane content — all in parallel. Layers are stable so the
// CSS transform is safe.
const rightShift = 240;
await Promise.all([
zoomTo(page, {
scale: 1.35,
focusX: cluster.x + rightShift,
focusY: cluster.y,
durationMs: 520,
}),
scrollPaneTo(page, '[data-tutorial="right-pane"]', 480),
]);
await sleep(320);
await showCaption(
page,
'This is the useful pause: local stats, matching homes, and street context together.'
);
await sleep(1800);
await sleep(520);
await hideCaption(page);
}
/** Export the current shortlist, then reveal the URL. */
export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'When the shortlist feels right, export the exact filtered view.');
await zoomReset(page, 460);
await sleep(240);
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: 360 });
ctx.cursor = target;
await sleep(70);
const download = page.waitForEvent('download', { timeout: 4000 }).catch(() => null);
await page.mouse.click(target.x, target.y);
await flashRect(page, box);
await sleep(280);
await hideCaption(page);
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
void download;
await sleep(2400);
}
/**
* Helper used by record.ts: after navigation but BEFORE the scene timer
* starts, click the AI-prompt button so its textarea is mounted, then snap
* the wrapper to its zoomed-on-AI starting state.
*
* Splitting this out keeps the scene timer honest: the textarea's mount
* animation and the zoom snap don't eat into the 15s budget.
*/
export async function preZoomToAiBox(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
// Open the AI prompt. The collapsed state shows a single button; clicking
// it expands the form and reveals the textarea.
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<HTMLElement>('[data-tutorial="ai-filters"] button')?.click();
});
}
await textarea.waitFor({ state: 'visible', timeout: 15000 });
await sleep(100);
// Snap-zoom to the AI card centre. The recording opens already zoomed in
// — there's no awkward "from 1× to 2.4×" intro animation.
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 zoomToInstant(page, { scale: AI_ZOOM_SCALE, focusX, focusY });
}