Nine scenes of 6-7 s, one slow camera move each, soft dissolves, one plain-English caption per scene at the top (on screen at least 4 s): the stores' to-do list and one rejection mail, `init` in an empty folder, the agent prompt and `release plan` (25 steps), store text and renders in 7 sizes x 4 languages, `play products --dry` and `catalog diff`, the scan's declarations proposal with the ask-owner line, three findings with plain reasons turning green, the desktop release timeline and Store readiness "No blockers", Submit and the end card. Every CLI line is real output: tests/teaser.test.ts runs each command (store answers from tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml, never the network) and checks scene lengths, caption holds, the 55-65 s total and 16:9 only. The vertical output is gone; the web version targets 4.5 MB. Calmer music (D major pads, soft pluck, bells on scene changes). desktop.ts adds the Store readiness frame.
135 lines
7.3 KiB
TypeScript
135 lines
7.3 KiB
TypeScript
#!/usr/bin/env bun
|
|
// Real desktop-app frames for the teaser's desktop scene: 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
|
|
// 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)
|
|
// render.ts runs it when out/desktop/ is missing; run it by hand after a UI change: bun media/teaser/desktop.ts
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { launch } from '../../cli/shots/browser';
|
|
|
|
const DIR = import.meta.dir;
|
|
const ROOT = join(DIR, '../..');
|
|
const 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 };
|
|
|
|
export async function captureDesktop(): Promise<void> {
|
|
mkdirSync(OUT, { recursive: true });
|
|
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<string, Box> = {};
|
|
const errors: string[] = [];
|
|
|
|
const session = async (clean: boolean) => {
|
|
const ctx = await browser.newContext({ viewport: VIEW, deviceScaleFactor: 2, colorScheme: 'dark', reducedMotion: 'reduce' });
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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');
|
|
|
|
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();
|
|
|
|
writeFileSync(join(OUT, 'boxes.json'), `${JSON.stringify(boxes, null, 1)}\n`);
|
|
await browser.close();
|
|
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
|
log(`6 frames + boxes.json -> ${OUT}`);
|
|
} finally {
|
|
vite.kill();
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) await captureDesktop();
|