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.
230 lines
13 KiB
TypeScript
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();
|