#!/usr/bin/env bun // Real desktop-app frames for the teaser's desktop scenes: the Vite dev server of apps/desktop in fixture mode // (VITE_FIXTURES=1: example.com-style fixture data, no Tauri, no store accounts), captured headlessly, dark theme, // with the two dev-build markers hidden (they are not in a release build). // Writes media/teaser/out/desktop/*.jpg plus boxes.json (callout targets, as fractions of the frame). // portfolio.jpg Portfolio: every app of every studio, grouped by studio // portfolio-apps.jpg the same, scrolled to the Apps table: both stores per app, rollout %, crash-rate sparklines // growth.jpg Prism Puddle's Growth tab: the store analytics chart (visx), pointer outside // growth-NN.jpg the same while the pointer (real mouse moves) sweeps across the chart: one frame per hovered // day, the crosshair and tooltip showing that day's impressions, downloads and conversion // health.jpg the Health tab: the crash and ANR rate charts, pointer outside // health-NN.jpg the pointer sweeping across the crash-rate chart, one frame per hovered day // prices.jpg the Prices tab: products x key territories, both stores' prices per country, gaps marked // releases.jpg Prism Puddle's release timeline, both stores rolling out at 20% // releases-empty.jpg the same frame with the rollout bars' fill and "20%" hidden (the video fills the bar) // checks.jpg the Checks tab: the "Rejection risks" card with its findings // checks-clean.jpg the same tab after the fixes: the fixture's scan answer with no findings (the app's own // "No rejection risks found" state; the fixture module is served with an empty findings list) // ready.jpg the same tab scrolled to "Store readiness" (validate, both stores) after the fixes: the // fixture's validate answer with no findings (the app's own "No blockers" state) // console-plan.jpg App info > Console steps > App Privacy > Plan: the console plan panel with the REAL plan // (`storebound console plan apple-app-privacy --json` run in a fresh `storebound init` folder, // served in place of the fixture's shortened plan), scrolled to the answer sheet's end and the // final button that stays the owner's (Publish) with the never-press list // console-plan-top.jpg the same panel at its top: the start page and the first steps // resubmit.jpg Tidy Lists' Releases > Send for review: back from App Review (UNRESOLVED_ISSUES), the rejected // version item, build 52 picked, Resubmit // submit-ready.jpg Prism Puddle's Releases > Send for review: build 52 picked, before "Submit for review" is pressed // submit-plan.jpg Prism Puddle's Send for review: build 52 picked, Submit for review pressed: the plan panel with // the dry run (nothing sent) and "Continue", the in-app step before the system dialog (the // native macOS dialog is not part of the web view, so it is never drawn here) // submit-waiting.jpg the same card after the submit: the fixture's apple status with the new submission waiting // for review (the app's own "Waiting for review" state) // render.ts runs it when out/desktop/ is missing; run it by hand after a UI change: bun media/teaser/desktop.ts import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { launch } from '../../cli/shots/browser'; const DIR = import.meta.dir; const ROOT = join(DIR, '../..'); const DEFAULT_OUT = join(DIR, 'out/desktop'); export const VIEW = { width: 1280, height: 800 }; const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [desktop] ${m}`); type Box = { x: number; y: number; w: number; h: number }; /** One frame of a hover sweep: the pointer position (fraction of the frame) at which this day starts to show. */ type SweepFrame = { file: string; px: number; py: number; day: string }; /** Hover sweeps: the chart, the part of its width the pointer crosses, the height it moves at. */ const SWEEPS = { growth: { start: 'app:growth', svg: '.lchart svg', x: [0.2, 0.86], y: 0.42 }, health: { start: 'app:health', svg: '.card:has-text("User-perceived crash rate") .spark svg', x: [0.12, 0.9], y: 0.45 }, } as const; /** The real `storebound console plan
--json` of a fresh `storebound init` folder (offline). */ export function realConsolePlan(form: string): Record { const dir = mkdtempSync(join(tmpdir(), 'sb-teaser-plan-')); try { const run = (argv: string[]) => Bun.spawnSync(['bun', join(ROOT, 'cli/index.ts'), ...argv], { cwd: dir, env: { ...process.env, NO_COLOR: '1' }, stdout: 'pipe', stderr: 'pipe' }); if (run(['init']).exitCode !== 0) throw new Error('storebound init failed'); const r = run(['console', 'plan', form, '--json']); if (r.exitCode !== 0) throw new Error(`console plan ${form} failed: ${r.stderr.toString().slice(-400)}`); return JSON.parse(r.stdout.toString()) as Record; } finally { rmSync(dir, { recursive: true, force: true }); } } /** `scale` is the device scale of the captures (2: 2560x1600, the default; 3 for the 4K master), `dir` where they go. */ export async function captureDesktop(o: { scale?: number; dir?: string } = {}): Promise { const OUT = o.dir ?? DEFAULT_OUT, SCALE = o.scale ?? 2; mkdirSync(OUT, { recursive: true }); const privacyPlan = realConsolePlan('apple-app-privacy'); const port = 14_300 + Math.floor(Math.random() * 500); const vite = Bun.spawn(['bunx', 'vite', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { cwd: join(ROOT, 'apps/desktop'), env: { ...process.env, VITE_FIXTURES: '1' }, stdout: 'ignore', stderr: 'pipe', }); const base = `http://127.0.0.1:${port}`; try { for (let i = 0; ; i++) { try { if ((await fetch(base)).ok) break; } catch { /* not up yet */ } if (i > 120) throw new Error('vite dev server did not start'); await Bun.sleep(250); } const browser = await launch(ROOT, { headless: true }); const boxes: Record = {}; const sweeps: Record = {}; const errors: string[] = []; const session = async (clean: boolean, waiting = false) => { const ctx = await browser.newContext({ viewport: VIEW, deviceScaleFactor: SCALE, colorScheme: 'dark', reducedMotion: 'reduce' }); // the console plan panel shows the real plan of the CLI instead of the fixture's shortened one; after the submit // (waiting), the fixture's apple status holds the new submission waiting for review await ctx.route(/\/src\/dev\/submit-fixtures\.ts/, async (route: any) => { const res = await route.fetch(); const src = await res.text(); // (Vite serves the module as JS: types stripped, double quotes) let body = src.replace(/export function consolePlan\(form(?::\s*string)?\)\s*\{/, (m: string) => `${m}\n if (form === 'apple-app-privacy') return ${JSON.stringify(privacyPlan)};`); if (body === src) errors.push('submit-fixtures.ts: consolePlan moved (desktop.ts)'); if (waiting) { if (!/export const APPLE_STATUS_READY = \{/.test(body) || !/const version = /.test(body)) errors.push('submit-fixtures.ts: APPLE_STATUS_READY moved (desktop.ts waiting capture)'); // the submission the submit made (rs-new-1, the id the fixture's progress lines name), waiting for review body += `\nAPPLE_STATUS_READY.versions = [version('ver-240', '2.4.0', 'WAITING_FOR_REVIEW', 'b-52'), ...APPLE_STATUS_READY.versions.slice(1)]; APPLE_STATUS_READY.subs = [{ id: 'rs-new-1', attributes: { state: 'WAITING_FOR_REVIEW', submittedDate: '${new Date(Date.now() - 60_000).toISOString()}' } }, ...APPLE_STATUS_READY.subs.slice(1)]; APPLE_STATUS_READY.emptyDrafts = [];\n`; } await route.fulfill({ response: res, body }); }); if (clean) { // after the fixes: the fixture's scan of Prism Puddle answers with no findings (the UI is unchanged) await ctx.route(/\/src\/dev\/fixtures\.ts/, async (route: any) => { const res = await route.fetch(); const src = await res.text(); const scan = src.replace(/findings:\s*SCAN_PRISM,\s*counts:\s*scanCounts\(SCAN_PRISM\)/, 'findings: [], counts: scanCounts([])'); if (scan === src) errors.push('fixtures.ts: the Prism Puddle scan entry moved (desktop.ts clean capture)'); // ... and its store readiness report (validate) answers with no blockers and no findings const body = scan.replace(/(["']p-prism["']:\s*\{\s*blockers:\s*)1(,\s*findings:\s*)\[[^\]]*\]/, '$10$2[]'); if (body === scan) errors.push('fixtures.ts: the Prism Puddle validate entry moved (desktop.ts clean capture)'); await route.fulfill({ response: res, body }); }); } const page = await ctx.newPage(); page.on('pageerror', (e: unknown) => errors.push(String(e))); return { ctx, page }; }; const open = async (page: any, start: string): Promise => { await page.goto(`${base}/?start=${encodeURIComponent(start)}&theme=dark&lang=en`); await page.waitForLoadState('networkidle'); await page.addStyleTag({ content: '.brand small, .dev-pill { display: none !important; }' }); await page.waitForTimeout(1200); }; const box = async (page: any, name: string, loc: any): Promise => { const b = await loc.first().boundingBox(); if (!b) throw new Error(`no box for ${name}`); boxes[name] = { x: b.x / VIEW.width, y: b.y / VIEW.height, w: b.width / VIEW.width, h: b.height / VIEW.height }; }; const shot = async (page: any, name: string): Promise => { await page.mouse.move(VIEW.width - 2, VIEW.height - 2); await page.waitForTimeout(200); await page.screenshot({ path: join(OUT, `${name}.jpg`), type: 'jpeg', quality: 93 }); }; const a = await session(false); await open(a.page, 'portfolio'); await box(a.page, 'studios', a.page.locator('.fchips')); await box(a.page, 'table', a.page.locator('table').first()); await shot(a.page, 'portfolio'); // the Apps table: both stores per app, rollouts, crash-rate sparklines await a.page.evaluate(() => document.getElementById('content')?.scrollBy({ top: 140 })); await a.page.waitForTimeout(300); await box(a.page, 'appsTable', a.page.locator('table').first()); await box(a.page, 'prismRow', a.page.locator('tr').filter({ hasText: 'Prism Puddle' }).first()); await shot(a.page, 'portfolio-apps'); // charts: the Growth tab's store analytics and the Health tab's crash rate, then a pointer sweep across each const sweep = async (name: keyof typeof SWEEPS, scroll: number): Promise => { const sw = SWEEPS[name]; await open(a.page, sw.start); if (scroll) await a.page.evaluate((y: number) => document.getElementById('content')?.scrollBy({ top: y }), scroll); await a.page.waitForTimeout(300); await shot(a.page, name); const svg = a.page.locator(sw.svg).first(); const b = await svg.boundingBox(); if (!b) throw new Error(`no chart for ${name}`); const y = b.y + b.height * sw.y; const frames: SweepFrame[] = []; let last = ''; for (let x = b.x + b.width * sw.x[0]; x <= b.x + b.width * sw.x[1]; x += 2) { await a.page.mouse.move(x, y); // the tooltip's text names the hovered day (and its values): a new text = a new day const day = ((await a.page.locator('.vx-tip').first().textContent({ timeout: 2000 }).catch(() => '')) ?? '').trim(); if (!day || day === last) continue; last = day; const file = `${name}-${String(frames.length).padStart(2, '0')}`; await a.page.waitForTimeout(60); await a.page.screenshot({ path: join(OUT, `${file}.jpg`), type: 'jpeg', quality: 93 }); frames.push({ file, px: x / VIEW.width, py: y / VIEW.height, day }); } if (frames.length < 8) throw new Error(`${name}: the sweep hovered only ${frames.length} days`); sweeps[name] = frames; }; await sweep('growth', 150); await box(a.page, 'growthPlot', a.page.locator('.lchart-plot').first()); await sweep('health', 0); await box(a.page, 'crashCard', a.page.locator('.card').filter({ hasText: 'User-perceived crash rate' }).first()); // prices: products x key territories, both stores' prices per country, the gaps marked await open(a.page, 'app:prices'); const matrix = a.page.locator('.card').filter({ hasText: 'Products x key territories' }).first(); await matrix.evaluate((el: HTMLElement) => { el.scrollIntoView({ block: 'start' }); document.getElementById('content')?.scrollBy({ top: -125 }); }); await a.page.waitForTimeout(300); await box(a.page, 'priceMatrix', matrix); await box(a.page, 'priceGap', matrix.locator('.chip').filter({ hasText: /Δ 2\d/ }).first()); await shot(a.page, 'prices'); await open(a.page, 'app:releases'); // the release timeline (both stores, rollout bars) at the top of the frame await a.page.locator('.tl-lane').first().evaluate((el: HTMLElement) => { el.scrollIntoView({ block: 'start' }); document.getElementById('content')?.scrollBy({ top: -150 }); }); await a.page.waitForTimeout(300); await box(a.page, 'roll', a.page.locator('.tl-roll').first()); await box(a.page, 'roll2', a.page.locator('.tl-roll').nth(1)); await box(a.page, 'timeline', a.page.locator('.tl-lane').first()); await shot(a.page, 'releases'); await a.page.addStyleTag({ content: '.tl-roll .rbar rect:not(.track) { display: none !important; } .tl-roll .pct { visibility: hidden !important; }' }); await a.page.waitForTimeout(150); await shot(a.page, 'releases-empty'); await open(a.page, 'app:checks'); await box(a.page, 'scan', a.page.locator('.card').first()); await box(a.page, 'finding', a.page.locator('.card').first().locator('.li').first()); await box(a.page, 'scanAgain', a.page.locator('.card').first().locator('.btn').first()); await shot(a.page, 'checks'); await a.ctx.close(); const b = await session(true); await open(b.page, 'app:checks'); await box(b.page, 'scanClean', b.page.locator('.card').first()); await shot(b.page, 'checks-clean'); // the store readiness card (validate) at the top of the view: "No blockers" for both stores // (room below the last row, so the card can scroll up to the top of the view) await b.page.addStyleTag({ content: '#content { padding-bottom: 640px !important; }' }); const ready = b.page.locator('.card').filter({ hasText: 'Store readiness' }).first(); await ready.evaluate((el: HTMLElement) => { el.scrollIntoView({ block: 'start' }); document.getElementById('content')?.scrollBy({ top: -120 }); }); await b.page.waitForTimeout(300); await box(b.page, 'ready', ready); await box(b.page, 'readyEmpty', ready.locator('.empty, [class*=empty]').first()); await shot(b.page, 'ready'); await b.ctx.close(); // the read / dry-run toasts are closed (their own close button), as the owner would, so they cover nothing const clearToasts = async (page: any): Promise => { for (let i = 0; i < 12 && await page.locator('.toasts .toast').count(); i++) { await page.locator('.toasts .toast .toast-top button').first().click().catch(() => {}); await page.waitForTimeout(250); } }; // the console plan panel (App info > Console steps > App Privacy > Plan) with the real plan const c = await session(false); await open(c.page, 'app:appinfo'); await c.page.locator('[data-form="apple-app-privacy"] button', { hasText: 'Plan' }).first().click(); await c.page.waitForSelector('.cp-panel'); await c.page.waitForTimeout(800); await clearToasts(c.page); const cpText = await c.page.locator('.cp-panel').innerText(); for (const w of ['App Store Connect: App Privacy', '40 steps', 'The final button (Publish) is yours']) if (!cpText.includes(w)) errors.push(`console plan panel: missing "${w}"`); await box(c.page, 'cpTitle', c.page.locator('.cp-panel h2')); await box(c.page, 'cpSteps', c.page.locator('.cp-panel .cp-steps')); await shot(c.page, 'console-plan-top'); const fin = c.page.locator('.cp-panel .notice', { hasText: 'The final button' }).first(); await fin.evaluate((el: HTMLElement) => el.scrollIntoView({ block: 'center' })); await c.page.waitForTimeout(300); await box(c.page, 'cpFinal', fin); await shot(c.page, 'console-plan'); await c.page.keyboard.press('Escape'); // send for review: Tidy Lists is back from App Review (unresolved), build 52 picked, Resubmit const submitSection = async (page: any, appName: string, ready = '[data-section="submit"] select'): Promise => { await open(page, 'app:releases'); await page.locator('nav.side .nav-app').filter({ hasText: appName }).first().click(); await page.waitForTimeout(400); await page.locator('[role="tab"]#tab-releases').click(); // the store reads are not cached in the fixture: press each card's "Read state", as the owner would await page.waitForSelector('[data-section="submit"]'); for (const b of await page.locator('[data-section="submit"] button', { hasText: 'Read state' }).all()) await b.click(); await page.waitForSelector(ready); await page.waitForTimeout(1500); await clearToasts(page); await page.locator('[data-section="submit"]').evaluate((el: HTMLElement) => { el.scrollIntoView({ block: 'start' }); document.getElementById('content')?.scrollBy({ top: -70 }); }); await page.waitForTimeout(300); }; await submitSection(c.page, 'Tidy Lists'); await c.page.locator('[data-section="submit"] select').first().selectOption('52'); await c.page.waitForTimeout(300); const unres = c.page.locator('[data-sub-kind="unresolved"]').first(); if (!(await unres.count())) errors.push('resubmit: no "back from App Review" submission'); await box(c.page, 'unresolvedItem', unres); await box(c.page, 'resubmitBtn', c.page.locator('[data-section="submit"] button', { hasText: 'Resubmit' }).first()); await shot(c.page, 'resubmit'); // Prism Puddle: build 52 picked, Submit for review: the plan panel (dry run) with Continue await submitSection(c.page, 'Prism Puddle'); await c.page.locator('[data-section="submit"] select').first().selectOption('52'); await c.page.waitForTimeout(300); // before the press: build 52 picked, "Submit for review" the one button left (the owner's) const submitBtn = c.page.locator('[data-section="submit"] button', { hasText: 'Submit for review' }).first(); await box(c.page, 'submitBtn', submitBtn); await shot(c.page, 'submit-ready'); await submitBtn.click(); await c.page.waitForSelector('.oa-panel'); await c.page.waitForTimeout(800); await clearToasts(c.page); const oaText = await c.page.locator('.oa-panel').innerText(); for (const w of ['Plan (dry run, nothing sent yet)', 'attach build 52', 'Continue: Submit for review']) if (!oaText.includes(w)) errors.push(`submit plan panel: missing "${w}"`); await box(c.page, 'oaPlan', c.page.locator('.oa-panel pre.plan-table')); await box(c.page, 'oaContinue', c.page.locator('.oa-panel .lb-foot button', { hasText: 'Continue' })); await shot(c.page, 'submit-plan'); await c.ctx.close(); // after the submit: the new submission waiting for review const w = await session(false, true); await submitSection(w.page, 'Prism Puddle', '[data-sub-kind="waiting"]'); const wait = w.page.locator('[data-sub-kind="waiting"]').first(); if (!(await wait.count())) errors.push('submit-waiting: no "Waiting for review" submission'); await box(w.page, 'waitingItem', wait); await shot(w.page, 'submit-waiting'); await w.ctx.close(); const source = 'apps/desktop, Vite dev server, VITE_FIXTURES=1, headless Chromium 1280x800 @2x, dark'; writeFileSync(join(OUT, 'boxes.json'), `${JSON.stringify({ ...boxes, source, sweeps }, null, 1)}\n`); await browser.close(); if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`); const n = 15 + Object.values(sweeps).reduce((k, f) => k + f.length, 0); log(`${n} frames (${Object.entries(sweeps).map(([k, f]) => `${k} sweep: ${f.length} days`).join(', ')}) + boxes.json -> ${OUT}`); } finally { vite.kill(); } } if (import.meta.main) await captureDesktop();