#!/usr/bin/env bun // Real frames of the storebound desktop app for the website and the README: the Vite dev server of apps/desktop in // fixture mode (VITE_FIXTURES=1: made-up example.com-style data for the demo game "Prism Puddle" and the other demo // apps, no Tauri, no store accounts), captured headlessly at 1280x800 @2x in both themes, with the two dev-build // markers hidden (they are not in a release build). Nothing is sent anywhere: the owner actions and console plans // only show the fixture's dry run; no "Continue" or final button is pressed. // // Views (each as -light.webp and -dark.webp in site/img/desktop/): // portfolio Portfolio: every app of every studio, both stores // send-for-review Prism Puddle > Releases > "Send for review": build 52 picked, "Submit for review" pressed, so the // plan panel (dry run table + "Continue" confirm) is open // checks Prism Puddle > Checks: the "Rejection risks" card (scan findings) at the top // growth Prism Puddle > Growth: the store analytics chart with the pointer mid-chart (crosshair + tooltip) // catalog Prism Puddle > Catalog: the parity matrix, every product on both stores // prices Prism Puddle > Prices: the "Products x key territories" matrix // testflight Prism Puddle > TestFlight // console-plan Prism Puddle > Releases > Google Play card > "Send changes for review": the console plan panel // (steps, answer sheet, never-press list) // // Each view is checked in the page (expected text visible, every '@' address in the visible text on example.com, zero // page errors; the transient toasts are hidden); the PNGs go to a temp dir, cwebp encodes them (q 76, lowered to 65 // at most until a file is <= 150 KB). SHOTS_DEBUG=1 prints the text inside each frame (for alt texts). // Needs cwebp (brew install webp) and the desktop app's dependencies (bun install in apps/desktop). // Run: bun tools/site-desktop-shots.ts (SHOTS_TMP= to keep the PNGs somewhere else) import { mkdirSync, mkdtempSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeBrowser, launch } from '../cli/shots/browser'; const ROOT = join(import.meta.dir, '..'); const OUT = join(ROOT, 'site/img/desktop'); const VIEW = { width: 1280, height: 800 }; const MAX_BYTES = 150 * 1024; const Q_START = 76; const Q_MIN = 65; const WAIT = 15_000; const THEMES = ['light', 'dark'] as const; type Theme = (typeof THEMES)[number]; const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [site-shots] ${m}`); /** A view: the start route, what to do before the shot, and text that must be visible in it. */ interface View { name: string; start: string; prepare: (page: any) => Promise; expect: string[] } const scrollTo = async (loc: any, offset: number): Promise => { await loc.first().waitFor({ state: 'visible', timeout: WAIT }); await loc.first().evaluate((el: HTMLElement, off: number) => { el.scrollIntoView({ block: 'start' }); document.getElementById('content')?.scrollBy({ top: -off }); }, offset); }; /** The send-for-review section of the Releases tab, its store state read (fixture answers). */ const submitSection = async (page: any): Promise => { const section = page.locator('section[data-section="submit"]'); await scrollTo(section, 20); // store reads run on demand: press each card's "Read state" button if it has not been read yet for (const title of ['App Store: submit for review', 'Google Play: production release']) { const card = section.locator('.card').filter({ hasText: title }).first(); await card.waitFor({ state: 'visible', timeout: WAIT }); const read = card.locator('button', { hasText: 'Read state' }); if (await read.count()) await read.first().click({ timeout: WAIT }); await card.locator('select').first().waitFor({ state: 'visible', timeout: WAIT }); } }; const VIEWS: View[] = [ { name: 'portfolio', start: 'portfolio', expect: ['Portfolio', 'Prism Puddle', 'Tidy Lists', 'Orbit Hop'], prepare: async () => {} }, { name: 'send-for-review', start: 'app:releases', expect: ['Send for review', 'Submit for review', 'Plan (dry run, nothing sent yet)', 'Continue: Submit for review', 'build 52'], prepare: async page => { await submitSection(page); const apple = page.locator('section[data-section="submit"] .card').filter({ hasText: 'App Store: submit for review' }).first(); await apple.locator('select').first().selectOption('52', { timeout: WAIT }); await apple.locator('button', { hasText: 'Submit for review' }).first().click({ timeout: WAIT }); await page.locator('.oa-panel .plan-table').waitFor({ state: 'visible', timeout: WAIT }); }, }, { name: 'checks', start: 'app:checks', expect: ['Checks', 'Rejection risks'], prepare: async page => { const first = page.locator('#content .card').first(); await first.waitFor({ state: 'visible', timeout: WAIT }); if (!((await first.innerText({ timeout: WAIT })) as string).includes('Rejection risks')) throw new Error('checks: the first card is not "Rejection risks"'); await first.locator('.li').first().waitFor({ state: 'visible', timeout: WAIT }); }, }, { name: 'growth', start: 'app:growth', expect: ['Growth', 'Store analytics'], prepare: async page => { await scrollTo(page.locator('.card').filter({ hasText: 'Store analytics' }), 20); await page.waitForTimeout(300); const svg = page.locator('.lchart svg').first(); await svg.waitFor({ state: 'visible', timeout: WAIT }); const b = await svg.boundingBox(); if (!b) throw new Error('growth: no chart box'); // a real pointer move, in steps, to the middle of the chart: crosshair and tooltip await page.mouse.move(b.x + b.width * 0.3, b.y + b.height * 0.42); await page.mouse.move(b.x + b.width * 0.55, b.y + b.height * 0.42, { steps: 12 }); await page.locator('.vx-tip').first().waitFor({ state: 'visible', timeout: WAIT }); }, }, { name: 'catalog', start: 'app:catalog', expect: ['Catalog', 'Parity matrix', 'coins_small', 'vip_pass_monthly'], prepare: async page => { await scrollTo(page.locator('.card').filter({ hasText: 'Parity matrix' }), 20); }, }, { name: 'prices', start: 'app:prices', expect: ['Prices', 'Products x key territories'], prepare: async page => { await scrollTo(page.locator('.card').filter({ hasText: 'Products x key territories' }), 20); }, }, { name: 'testflight', start: 'app:testflight', expect: ['TestFlight', 'Recent builds'], prepare: async page => { await page.locator('#content .card').first().waitFor({ state: 'visible', timeout: WAIT }); }, }, { name: 'console-plan', start: 'app:releases', expect: ['Play Console: send the first production release for review', '4 steps', 'answers from your config', 'Never pressed by anyone else'], prepare: async page => { await submitSection(page); const play = page.locator('section[data-section="submit"] .card').filter({ hasText: 'Google Play: production release' }).first(); await play.locator('button', { hasText: 'Send changes for review' }).first().click({ timeout: WAIT }); await page.locator('.cp-panel .cp-steps').waitFor({ state: 'visible', timeout: WAIT }); }, }, ]; /** Encodes one PNG to WebP, lowering the quality until it fits MAX_BYTES (never below Q_MIN). */ async function encode(png: string, webp: string): Promise<{ bytes: number; q: number }> { for (let q = Q_START; q >= Q_MIN; q -= 3) { const p = Bun.spawn(['cwebp', '-quiet', '-q', String(q), '-m', '6', '-resize', String(VIEW.width), '0', png, '-o', webp], { stdout: 'ignore', stderr: 'pipe' }); const timer = setTimeout(() => p.kill(), 60_000); const code = await p.exited; clearTimeout(timer); if (code !== 0) throw new Error(`cwebp failed for ${png}: ${await new Response(p.stderr).text()}`); const bytes = statSync(webp).size; if (bytes <= MAX_BYTES) return { bytes, q }; } throw new Error(`${webp}: over ${MAX_BYTES / 1024} KB even at q ${Q_MIN}`); } /** Visible text checks: the expected strings, and every e-mail-like address on example.com. */ async function checkText(page: any, view: View, theme: Theme): Promise { const text: string = await page.evaluate(() => document.body.innerText); const missing = view.expect.filter(s => !text.includes(s)); if (missing.length) throw new Error(`${view.name}-${theme}: expected text not visible: ${missing.join(', ')}`); const ats = text.match(/[^\s@]*@[^\s@]*/g) ?? []; const bad = ats.filter(a => !/^[\w.+-]+@([\w-]+\.)*example\.(com|org|net)\b/i.test(a)); if (bad.length) throw new Error(`${view.name}-${theme}: '@' outside example.com in the visible text: ${bad.join(', ')}`); if (process.env.SHOTS_DEBUG) { // the text inside the frame only (for alt texts): text nodes whose box intersects the viewport, topmost layer const inView: string = await page.evaluate(() => { const out: string[] = []; const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); for (let n = w.nextNode(); n; n = w.nextNode()) { const s = n.textContent?.trim(); if (!s) continue; const r = document.createRange(); r.selectNodeContents(n); const b = r.getBoundingClientRect(); if (b.width === 0 || b.bottom <= 0 || b.top >= innerHeight || b.right <= 0 || b.left >= innerWidth) continue; const top = document.elementFromPoint(Math.min(innerWidth - 1, Math.max(0, b.left + 2)), Math.min(innerHeight - 1, Math.max(0, b.top + b.height / 2))); if (top && !(top === n.parentElement || n.parentElement?.contains(top) || top.contains(n.parentElement))) continue; out.push(s); } return out.join(' | '); }); console.log(`--- ${view.name}-${theme} ---\n${inView}\n`); } } async function main(): Promise { const which = Bun.spawnSync(['which', 'cwebp']); if (which.exitCode !== 0) throw new Error('cwebp not found (brew install webp)'); mkdirSync(OUT, { recursive: true }); const tmp = process.env.SHOTS_TMP ?? mkdtempSync(join(tmpdir(), 'site-desktop-shots-')); mkdirSync(tmp, { recursive: true }); const port = 14_800 + 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}`; let browser: any = null; let total = 0; try { for (let i = 0; ; i++) { try { if ((await fetch(base, { signal: AbortSignal.timeout(2000) })).ok) break; } catch { /* not up yet */ } if (i > 120) throw new Error('vite dev server did not start within 30 s'); if (i % 20 === 0) log('waiting for the vite dev server ...'); await Bun.sleep(250); } browser = await launch(ROOT, { headless: true }); for (const theme of THEMES) { const ctx = await browser.newContext({ viewport: VIEW, deviceScaleFactor: 2, colorScheme: theme, reducedMotion: 'reduce' }); ctx.setDefaultTimeout(WAIT); ctx.setDefaultNavigationTimeout(30_000); try { for (const view of VIEWS) { const page = await ctx.newPage(); const errors: string[] = []; page.on('pageerror', (e: unknown) => errors.push(String(e))); await page.goto(`${base}/?start=${encodeURIComponent(view.start)}&theme=${theme}&lang=en`, { timeout: 30_000 }); await page.waitForLoadState('networkidle', { timeout: 30_000 }); // the dev-build markers (not in a release build) and the transient "... read" toasts of the store reads await page.addStyleTag({ content: '.brand small, .dev-pill, .toasts { display: none !important; }' }); await page.waitForTimeout(1000); if (view.name !== 'growth') await page.mouse.move(VIEW.width - 2, VIEW.height - 2); await view.prepare(page); if (view.name !== 'growth') await page.mouse.move(VIEW.width - 2, VIEW.height - 2); await page.waitForLoadState('networkidle', { timeout: 30_000 }); await page.waitForTimeout(1000); await checkText(page, view, theme); if (errors.length) throw new Error(`${view.name}-${theme}: page errors:\n ${[...new Set(errors)].join('\n ')}`); const png = join(tmp, `${view.name}-${theme}.png`); await page.screenshot({ path: png, type: 'png', timeout: 30_000 }); await page.close(); const webp = join(OUT, `${view.name}-${theme}.webp`); const { bytes, q } = await encode(png, webp); total += bytes; console.log(`site/img/desktop/${view.name}-${theme}.webp ${(bytes / 1024).toFixed(1)} KB (q ${q})`); } } finally { await ctx.close(); } } log(`${VIEWS.length * THEMES.length} files, ${(total / 1024).toFixed(1)} KB in all -> site/img/desktop/`); } finally { if (browser) await closeBrowser(browser).catch(() => {}); vite.kill(); if (!process.env.SHOTS_TMP) rmSync(tmp, { recursive: true, force: true }); } } if (import.meta.main) await main();