storebound/tools/site-desktop-shots.ts
CRT 710c3a53d0 README, docs and site: one story (the whole job, no avoidable rejections, the desktop app, your AI, install)
README is a short front page (about 120 lines) with one real visual per section; every former section moved
unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth,
studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one
page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight
screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools:
tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and
WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
2026-10-13 03:29:59 +08:00

230 lines
13 KiB
TypeScript

#!/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 <view>-light.webp and <view>-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=<dir> 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<void>; expect: string[] }
const scrollTo = async (loc: any, offset: number): Promise<void> => {
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<void> => {
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<void> {
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<void> {
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();