diff --git a/media/teaser/README.md b/media/teaser/README.md index d1f69e5..d8a3dfc 100644 --- a/media/teaser/README.md +++ b/media/teaser/README.md @@ -1,12 +1,12 @@ -# storebound teaser (55.5 s, 16:9) +# storebound teaser (62 s, 16:9) A calm, scripted motion piece: from an empty folder to everything ready on both stores. Every frame is a pure function of time, rendered from an HTML/CSS/JS timeline in headless Chrome and encoded with ffmpeg. The music and the sound design are original and synthesized from scratch in the same browser (Web Audio `OfflineAudioContext`): no samples, no downloaded audio, nothing to license. -One idea per scene, 4-5 s each, one camera move per scene (a quick ease-out that settles before the cut), short -dissolves on the cuts, no whips or rapid montages. Long terminal output streams faster rather than being cut. Each scene +One idea per scene, 4-5 s each (whole half seconds, so every cut lands on a beat of the music), one camera move per +scene (a quick ease-out that settles before the cut), short dissolves on the cuts, no whips or rapid montages. Long terminal output streams faster rather than being cut. Each scene has one plain-English caption at the top, fully on screen for at least 3 s; below it, the real product. | s | shows | caption | @@ -16,20 +16,25 @@ has one plain-English caption at the top, fully on screen for at least 3 s; belo | 9-14 | the prompt "Get my app ready for both stores."; `storebound release plan 1.0.0`: 26 steps in fix order | You ask once. It plans every step. | | 14-19 | the demo game's store text in 4 languages; its renders in 7 store sizes x 4 languages | Store text and screenshots. Every language, every size. | | 19-23.5 | `play products --dry` (174 regions), then `catalog diff` (175 / 174 region prices, 0 mismatches) | In-app products on both stores, priced in 175 countries. | -| 23.5-28.5 | the scan's declarations proposal: detected answers with file:line, one "ask owner" line highlighted; 764 Data safety answers | The forms, answered from your code. It asks what it can't know. | -| 28.5-33.5 | `storebound scan`: three findings, each with a plain reason; fixed, the re-scan is clean | It catches rejections before Apple does. | -| 33.5-38 | the desktop app's Portfolio: every app of three studios, App Store and Google Play per app, rollout %, crash-rate sparklines | Stop switching between two consoles. Every app, both stores, one window. | -| 38-42.5 | the Growth chart (visx) with the pointer sweeping across it, the crosshair and tooltip moving day by day; then the Health tab's crash rate, swept the same way | Installs, conversion and crashes, charted. | -| 42.5-47 | the Prices tab: products x key territories, both stores' prices per country, the gaps between them marked | Products and prices on both stores, side by side. | -| 47-51.5 | the release timeline with both stores on one track, then Store readiness: "No blockers" | Both stores, one view. Ready for review. | -| 51.5-55.5 | "Submit for Review" with the cursor resting on it (it never clicks), then the end card: storebound.dev, Open source · CLI · Desktop app | You press Submit. That's it. | +| 23.5-28.5 | the scan's declarations proposal: detected answers with file:line, one "ask owner" line highlighted; 768 Data safety answers | The forms, answered from your code. It asks what it can't know. | +| 28.5-33.5 | `storebound console plan apple-app-privacy`: steps, answer sheet, the never-press list and "Final button (the owner's): Publish" highlighted (40 steps, 92 answers); then the desktop app's console plan panel (App info) with that real plan, at the final button the owner keeps | No API? Your AI fills the console. It stops at Publish. | +| 33.5-38.5 | `storebound scan`: three findings, each with a plain reason; fixed, the re-scan is clean | It catches rejections before Apple does. | +| 38.5-43 | the desktop app's Portfolio: every app of three studios, App Store and Google Play per app, rollout %, crash-rate sparklines | Stop switching between two consoles. Every app, both stores, one window. | +| 43-47.5 | the Growth chart (visx) with the pointer sweeping across it, the crosshair and tooltip moving day by day; then the Health tab's crash rate, swept the same way | Installs, conversion and crashes, charted. | +| 47.5-52 | the Prices tab: products x key territories, both stores' prices per country, the gaps between them marked | Products and prices on both stores, side by side. | +| 52-57 | Releases › Send for review, back from App Review (UNRESOLVED_ISSUES): the rejected version item ("marked resolved when you resubmit"), build 52 picked, the pointer resting on Resubmit | Rejected? Fix it, resubmit in one click. | +| 57-62 | Submit for review: the plan panel with the dry run (nothing sent yet) and the pointer resting on "Continue" (it never clicks); then the card with the new submission "Waiting for review"; then the end card: storebound.dev, Open source · CLI · Desktop app | You press Submit. That's it. | + +v6's "Both stores, one view. Ready for review." scene (release timeline, Store readiness) does not fit the 62 s; its +point lives on in the submit scene's dry run. The native confirm dialog that follows "Continue" is the system's +(macOS), outside the web view, so the video shows the app's own plan step before it and never draws a system dialog. | file | what | |---|---| | `script.js` | the timeline: scenes in seconds, camera moves, captions, every line shown, the sound cues | -| `desktop.ts` | the desktop frames: apps/desktop's Vite dev server in fixture mode (`VITE_FIXTURES=1`, example.com-style data), captured headlessly in dark at 1280x800 @2x with the dev markers hidden, plus `boxes.json` (callout targets, the hover sweeps) -> `out/desktop/`. The chart hovers are real: the mouse moves across the chart in small steps and a frame is taken for every day the tooltip shows; the video moves its pointer the same way and shows the day it has reached | +| `desktop.ts` | the desktop frames: apps/desktop's Vite dev server in fixture mode (`VITE_FIXTURES=1`, example.com-style data), captured headlessly in dark at 1280x800 @2x with the dev markers hidden, plus `boxes.json` (callout and pointer targets, the hover sweeps) -> `out/desktop/`. The chart hovers are real: the mouse moves across the chart in small steps and a frame is taken for every day the tooltip shows; the video moves its pointer the same way and shows the day it has reached. The console plan panel gets the real `storebound console plan apple-app-privacy --json` of a fresh `storebound init` folder in place of the fixture's shortened plan; the send-for-review frames press "Read state", pick build 52 and open the plan panel as the owner would; the "Waiting for review" frame serves the fixture's apple status with the new submission waiting | | `index.html` + `teaser.js` | the picture; `window.teaser.seek(t)` sets every element for time `t` | -| `music.js` | music (D major, one chord per scene: pads, a slow soft pluck, a quiet sub) and soft sound design: a bell and an airy swell on each scene change, key clicks, quiet ticks, chimes for the fixes | +| `music.js` | music (D major, one chord per scene: pads, a soft pluck every half second, a quiet sub) and soft sound design: a bell and an airy swell on each scene change (each on a beat), key clicks, quiet ticks, chimes for the fixes and the submission | | `render.ts` | frames at 60 fps blended to 30 fps -> ffmpeg (H.264 high, yuv420p), sound -> loudnorm (-16 LUFS, -1.5 dBTP), mux, poster, the 1280x720 web version | | `icons/` | the mouse cursor and the UI icons: Phosphor files copied unchanged by `bun tools/site-icons.ts` (no hand-written SVG; CI checks it) | @@ -44,11 +49,14 @@ clock prefix and the "(0s)" timer left out): (the count Play answered in a recorded read-only run). - `storebound scan` of `tests/fixtures/scan/teaser` (findings, declarations proposal) and of `teaser-fixed` after the fixes, and the Data safety answer count of `storebound check`. +- `storebound console plan apple-app-privacy` in the init folder (offline): the lines in order, the step and answer + counts of the pill, the never-press list and the owner's final button. The plain reasons under the findings and the callouts on the desktop frames are annotations, set apart from the CLI text. The store text is `examples/demo-game/store/shots/copy.json`, the screenshots its real renders, the app icon its `store/icon/icon.png`. The mail row is fictional: no real names or addresses, no mail-app branding. The test also checks -the timing (scenes 4-5 s, captions on screen at least 3 s, 50-60 s in total, 16:9 only) and that every desktop frame a +the timing (scenes 4-5 s in whole half seconds, captions on screen at least 3 s, 60-65 s in total, 16:9 only, a chord +for every scene) and that every desktop frame a scene names is one `desktop.ts` captures, and, where they were captured, that each file is there (2560x1600 JPEG, one sweep frame per hovered day). @@ -60,11 +68,11 @@ game's store renders: ```sh cd examples/demo-game && bun serve.ts & bun ../../cli/index.ts shots capture && bun ../../cli/index.ts shots fonts && bun ../../cli/index.ts shots render # from the repo root -bun media/teaser/render.ts > media/teaser/out/render.log 2>&1 & # master, sound, poster, web version: about 15 min +bun media/teaser/render.ts > media/teaser/out/render.log 2>&1 & # master, sound, poster, web version: about 20 min bun media/teaser/render.ts --desktop # capture the desktop app frames again (after a UI change) -bun media/teaser/render.ts --stills 2,12.5,30 # PNG stills in out/stills for a quick layout check +bun media/teaser/render.ts --stills 2,30.5,57.8 # PNG stills in out/stills for a quick layout check bun media/teaser/render.ts --serve --port 8750 # live preview: /index.html?play=1 (or ?t=12.5) ``` Outputs (gitignored) in `media/teaser/out/`: `teaser-1920x1080.mp4` (master), `teaser-web.mp4` (1280x720, about -6 MB) with `poster-web.jpg`, `teaser-1920x1080-silent.mp4`, `poster-1920x1080.jpg`, `music.wav`. +6.7 MB) with `poster-web.jpg`, `teaser-1920x1080-silent.mp4`, `poster-1920x1080.jpg`, `music.wav`. diff --git a/media/teaser/desktop.ts b/media/teaser/desktop.ts index 63cdafc..c4949b6 100644 --- a/media/teaser/desktop.ts +++ b/media/teaser/desktop.ts @@ -18,8 +18,21 @@ // "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-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, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { launch } from '../../cli/shots/browser'; @@ -38,8 +51,21 @@ const SWEEPS = { 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 }); } +} + export async function captureDesktop(): Promise { 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', @@ -56,8 +82,25 @@ export async function captureDesktop(): Promise { const sweeps: Record = {}; const errors: string[] = []; - const session = async (clean: boolean) => { + const session = async (clean: boolean, waiting = false) => { const ctx = await browser.newContext({ viewport: VIEW, deviceScaleFactor: 2, 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) => { @@ -189,11 +232,87 @@ export async function captureDesktop(): Promise { 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.locator('[data-section="submit"] button', { hasText: 'Submit for review' }).first().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 = 9 + Object.values(sweeps).reduce((k, f) => k + f.length, 0); + const n = 14 + 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(); diff --git a/media/teaser/index.html b/media/teaser/index.html index 377da22..3b051de 100644 --- a/media/teaser/index.html +++ b/media/teaser/index.html @@ -152,7 +152,17 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; } #form .row .tg { margin-left: auto; font: 700 21px/1 var(--mono); color: var(--green); opacity: 0; } #forms-pill { left: 1490px; top: 900px; } -/* ---------- 7 rejections ---------- */ +/* ---------- 7 consoles ---------- */ +#cpterm { left: 160px; top: 290px; width: 1600px; } +#cpterm .body { padding: 18px 30px 22px; font-size: 24px; line-height: 1.5; } +#cpterm .ln.fin { position: relative; display: inline-block; margin: 6px 0 0 -14px; padding: 6px 14px; border-radius: 12px; } +#cpterm .ln.fin .glow { position: absolute; inset: 0; border-radius: 12px; background: var(--yellow-bg); border: 3px solid var(--yellow); opacity: 0; } +#cpterm .ln.fin > span:not(.glow) { position: relative; } +#cpterm .ln.fin .k { color: var(--yellow); font-weight: 800; } #cpterm .ln.fin .w { color: #fff; font-weight: 800; } +#cp-pill { left: 50%; top: 930px; } +.dhost { position: absolute; inset: 0; } + +/* ---------- 8 rejections ---------- */ #scanterm { left: 160px; top: 290px; width: 1600px; } #scanterm .body { padding: 18px 30px 24px; font-size: 26px; line-height: 1.45; } .fd { position: relative; margin: 10px 0; padding: 14px 22px 16px; border-radius: 16px; opacity: 0; } @@ -171,7 +181,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; } .fd .fixed { font: 800 22px/1 var(--mono); color: #052e16; background: var(--green); border-radius: 8px; padding: 6px 12px; opacity: 0; } #rescan .ln { display: block; opacity: 0; } -/* ---------- 8-11 desktop window ---------- */ +/* ---------- 7, 9-13 desktop window ---------- */ .win { position: absolute; border-radius: 20px; overflow: hidden; background: #0b0f19; border: 1.5px solid rgba(255,255,255,.12); box-shadow: 0 50px 120px rgba(0,0,0,.65), 0 0 0 1px rgba(0,0,0,.6); } .win .bar { position: absolute; left: 0; right: 0; top: 0; height: 40px; background: #161b27; border-bottom: 1px solid #222a3a; @@ -182,7 +192,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; } .win .view .cam2 { position: absolute; left: 0; top: 0; width: 1280px; height: 800px; transform-origin: 0 0; } .win .view .grp { position: absolute; left: 0; top: 0; width: 1280px; height: 800px; } .win .view img { position: absolute; left: 0; top: 0; display: block; width: 1280px; height: 800px; } -/* the pointer of a chart's hover sweep (same arrow as #cursor) */ +/* the pointer (Phosphor navigation-arrow, fill): a chart's hover sweep, or resting on a button (it never clicks) */ .dcur { position: absolute; z-index: 6; width: 52px; height: 52px; left: 0; top: 0; opacity: 0; filter: drop-shadow(0 0 1.5px #000) drop-shadow(0 0 1.5px #000) drop-shadow(0 8px 12px rgba(0,0,0,.55)); } .dcur .pi { position: absolute; inset: 0; width: 100%; height: 100%; color: #fff; } @@ -194,17 +204,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; } box-shadow: 0 0 0 6px rgba(139,147,255,.22), 0 0 40px rgba(139,147,255,.45); } .ring.green { border-color: var(--green); box-shadow: 0 0 0 6px rgba(61,220,132,.22), 0 0 40px rgba(61,220,132,.45); } -/* ---------- cursor (Phosphor navigation-arrow, fill) ---------- */ -#cursor { z-index: 55; width: 60px; height: 60px; left: 0; top: 0; opacity: 0; - filter: drop-shadow(0 0 1.5px #000) drop-shadow(0 0 1.5px #000) drop-shadow(0 8px 12px rgba(0,0,0,.55)); } -#cursor .pi { position: absolute; inset: 0; width: 100%; height: 100%; color: #fff; } - -/* ---------- 9 submit + brand ---------- */ -#sbtn { position: absolute; left: 50%; top: 520px; display: flex; align-items: center; gap: 22px; font: 800 60px/1 var(--display); - color: #fff; background: linear-gradient(#3b82f6, #2563eb); border-radius: 28px; padding: 42px 68px; letter-spacing: -0.01em; - box-shadow: 0 30px 80px rgba(37,99,235,.45), inset 0 2px 0 rgba(255,255,255,.25); white-space: nowrap; } -#sbtn .pi { font-size: 56px; } -#sbtn .hov { position: absolute; inset: 0; border-radius: 28px; background: rgba(255,255,255,.12); opacity: 0; } +/* ---------- 13 submit: the end card ---------- */ #brand { left: 0; right: 0; top: 330px; text-align: center; } #brand > * { opacity: 0; } #brand .mark { width: 190px; height: 190px; display: block; margin: 0 auto; filter: drop-shadow(0 30px 70px rgba(91,92,240,.6)); } @@ -252,6 +252,12 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
+
+
Terminaloffline · no store access
+
+
+
+
Terminal
@@ -259,10 +265,9 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
-
+
-
Submit for Review
storebound
@@ -273,7 +278,6 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
-
diff --git a/media/teaser/music.js b/media/teaser/music.js index e9b8c52..0105c09 100644 --- a/media/teaser/music.js +++ b/media/teaser/music.js @@ -17,9 +17,11 @@ function rng(seed) { // mulberry32 const CH = { Bm: [47, [0, 3, 7, 14]], G: [43, [0, 4, 7, 14]], D: [50, [0, 4, 7, 11]], A: [45, [0, 4, 7, 14]], Em: [52, [0, 3, 7, 10]], Dadd9: [50, [0, 4, 7, 14]], }; -const SCENE_CHORD = { hook: 'Bm', init: 'G', plan: 'D', store: 'A', products: 'Bm', forms: 'G', rejections: 'Em', portfolio: 'G', charts: 'D', catalog: 'Bm', desktop: 'A', submit: 'D' }; +const SCENE_CHORD = { hook: 'Bm', init: 'G', plan: 'D', store: 'A', products: 'Bm', forms: 'G', consoles: 'A', rejections: 'Em', portfolio: 'G', charts: 'D', catalog: 'Bm', resubmit: 'G', submit: 'D' }; const ARP = [0, 1, 2, 3, 2, 1, 2, 3]; -const STEP = 0.5; // one soft note every half second +/** One soft note every half second: every scene is a whole number of half seconds long (tests/teaser.test.ts), so each + * cut lands on a note of the arpeggio. */ +export const STEP = 0.5; export async function renderMusic() { const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(DURATION * SR) + SR * 2, sampleRate: SR }); diff --git a/media/teaser/render.ts b/media/teaser/render.ts index d516e1f..b783ee7 100644 --- a/media/teaser/render.ts +++ b/media/teaser/render.ts @@ -21,8 +21,8 @@ const args = process.argv.slice(2); const opt = (k: string): string | undefined => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : undefined; }; /** 16:9 only. */ const FORMAT = { w: 1920, h: 1080 }; -/** The web version: 1280x720 H.264, two-pass to about this many bytes (about 860 kbps for 55 s, as v5 had for 42 s). */ -const WEB_BYTES = 6e6; +/** The web version: 1280x720 H.264, two-pass to about this many bytes (about 770 kbps video for 62 s, as v6 had for 55.5 s). */ +const WEB_BYTES = 6.7e6; const CAPTURE_FPS = 60; const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [teaser] ${m}`); diff --git a/media/teaser/script.js b/media/teaser/script.js index f60ca6b..ae1e5ec 100644 --- a/media/teaser/script.js +++ b/media/teaser/script.js @@ -1,5 +1,5 @@ -// Teaser timeline (v6): the single source of truth for the picture (teaser.js) and the sound (music.js). -// One idea per scene, 4-5 s each (about 55 s in all), one camera move per scene with a quick ease-out, short dissolves +// Teaser timeline (v7): the single source of truth for the picture (teaser.js) and the sound (music.js). +// One idea per scene, 4-5 s each (62 s in all), one camera move per scene with a quick ease-out, short dissolves // on the cuts (no whips, no rapid montages). Long terminal output streams faster instead of being cut. Every scene has // one plain-English caption at the top that is fully on screen for at least 3 s; the lower part shows the real // product: CLI output and desktop-app frames. @@ -12,9 +12,11 @@ // to the config, the same store double (before: nothing on the stores; after: both products on both) // forms the declarations proposal of `storebound scan` (tests/fixtures/scan/teaser) and the Data safety // answer count of `storebound check` +// consoles `storebound console plan apple-app-privacy` in the init folder (offline) // rejections `storebound scan` of tests/fixtures/scan/teaser and, after the fixes, of teaser-fixed // The store text is examples/demo-game/store/shots/copy.json, the screenshots its real renders (render.ts copies -// them), the desktop frames the app in fixture mode (desktop.ts). The plain reasons under the findings and the +// them), the desktop frames the app in fixture mode (desktop.ts; its console plan panel shows the real plan of +// `console plan apple-app-privacy --json`). The plain reasons under the findings and the // callouts are annotations, set apart from the terminal text. export const FPS = 30; @@ -22,7 +24,7 @@ export const FPS = 30; export const FORMAT = { w: 1920, h: 1080 }; /** Scene lengths in seconds (4-5 s each). The next scene dissolves in over DISSOLVE s centred on the cut. */ -const LENGTHS = [['hook', 4.5], ['init', 4.5], ['plan', 5], ['store', 5], ['products', 4.5], ['forms', 5], ['rejections', 5], ['portfolio', 4.5], ['charts', 4.5], ['catalog', 4.5], ['desktop', 4.5], ['submit', 4]]; +const LENGTHS = [['hook', 4.5], ['init', 4.5], ['plan', 5], ['store', 5], ['products', 4.5], ['forms', 5], ['consoles', 5], ['rejections', 5], ['portfolio', 4.5], ['charts', 4.5], ['catalog', 4.5], ['resubmit', 5], ['submit', 5]]; export const SCENES = LENGTHS.reduce((a, [id, len]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len }]; }, []); export const DURATION = SCENES.at(-1).end; export const DISSOLVE = 0.4; @@ -39,13 +41,15 @@ export const CAMERA = { store: { from: { z: 1.02, x: 40, y: 0 }, to: { z: 1.02, x: -40, y: 0 } }, products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: 0, y: -10 } }, forms: { from: { z: 1.02, x: 30, y: 0 }, to: { z: 1.02, x: -30, y: 0 } }, + // the terminal, then the app's plan panel dissolving in over it: one slow push over both + consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.03, x: 0, y: 4 } }, rejections: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: -12 } }, // the desktop scenes: the push happens inside the app window (DESK) portfolio: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, charts: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, catalog: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, - desktop: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, - submit: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: 0 } }, + resubmit: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, + submit: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, }; /** Captions: one per scene, at the top. *word* = highlighted. Lines are set as written. */ @@ -56,11 +60,12 @@ export const CAPTIONS = [ { id: 'store', lines: ['Store text and screenshots.', '*Every language, every size.*'] }, { id: 'products', lines: ['In-app products on both stores,', 'priced in *175 countries.*'] }, { id: 'forms', lines: ['The forms, answered from your code.', 'It asks what it *can\'t know.*'] }, + { id: 'consoles', lines: ['No API? *Your AI fills the console.*', 'It stops at Publish.'] }, { id: 'rejections', lines: ['It catches rejections *before Apple does.*'], hl: 'green' }, { id: 'portfolio', lines: ['Stop switching between two consoles.', 'Every app, both stores, *one window.*'] }, { id: 'charts', lines: ['Installs, conversion and crashes, *charted.*'] }, { id: 'catalog', lines: ['Products and prices on both stores, *side by side.*'] }, - { id: 'desktop', lines: ['Both stores, one view. *Ready for review.*'] }, + { id: 'resubmit', lines: ['Rejected? Fix it, *resubmit in one click.*'] }, { id: 'submit', lines: ['You press *Submit.* That\'s it.'] }, ]; /** Caption fade: in over CAP_IN from CAP_DELAY after the scene starts; out over CAP_OUT, ending CAP_END before it ends. */ @@ -187,7 +192,31 @@ export const FORM_AT = 1.9, FORM_STEP = 0.14; export const DECL_TOTAL = 768; export const FORMS_PILL = { at: 3.0, text: 'Data safety answers' }; -// ---------- 7 rejections: three findings with a plain reason each, then fixed ---------- +// ---------- 7 consoles: the console plan of a form without an API, then the app's plan panel ---------- +export const CONSOLE_CMD = 'storebound console plan apple-app-privacy'; +/** The command is typed (fast), then the output streams. */ +export const CONSOLE_TYPE_AT = 0.05, CONSOLE_CPS = 120; +/** Real lines of the plan (a fresh `storebound init` folder); the other steps and answers left out. at = seconds into + * the scene. final = the owner's button, highlighted. */ +export const CONSOLE_LINES = [ + { at: 0.45, text: 'App Store Connect: App Privacy', head: true }, + { at: 0.6, text: 'Steps:', head: true }, + { at: 0.72, text: ' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar).' }, + { at: 0.84, text: ' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]' }, + { at: 0.96, text: ' 39. Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish, then Publish again in the "Publish Your App Privacy Responses?" dialog (it puts the answers on the App Store product page). Afterwards the page shows "Published … ago by …".' }, + { at: 1.14, text: 'Answer sheet:', head: true }, + { at: 1.26, text: ' [ok] Data type: Contact Info > Email Address: Yes -> tick "Email Address" @ the Data Collection dialog (Edit next to Data Types): the data type list' }, + { at: 1.38, text: ' [ask] Data type: User Content > Gameplay Content: (you decide) -> untick "Gameplay Content" @ the Data Collection dialog (Edit next to Data Types): the data type list' }, + { at: 1.56, text: 'Never press: "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"' }, + { at: 1.72, text: "Final button (the owner's): Publish", final: true }, +]; +/** Steps and answer-sheet rows in the whole plan (the numbered and the [ok] / [ask] lines of the real output). */ +export const CONSOLE_STEPS = 40, CONSOLE_ANSWERS = 92; +export const CONSOLES_PILL = { at: 1.95, text: `*${CONSOLE_STEPS}* steps · *${CONSOLE_ANSWERS}* answers · stops before *Publish*` }; +/** The terminal gives way to the app's plan panel (DESK.consoles) over this window, seconds into the scene. */ +export const CONSOLES_SWAP = [2.45, 0.5]; + +// ---------- 8 rejections: three findings with a plain reason each, then fixed ---------- export const SCAN_CMD = 'storebound scan'; export const RISKS_LINE = 'Rejection risks: 2 blocker, 3 high, 0 medium, 0 low'; /** Three of the five findings (real text); why = a plain-English annotation, not CLI output. */ @@ -204,13 +233,22 @@ export const FIX_AT = [2.7, 2.95, 3.2]; export const RESCAN_AT = 3.6; export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low'; -// ---------- 8-11 desktop: the app as the one console for both stores ---------- +// ---------- 7, 9-13 desktop: the app as the one console for both stores ---------- /** Real frames of the desktop app in fixture mode (desktop.ts), one entry per scene. groups: what the window shows, in * order; a group is one frame, or a frame plus a hover sweep (the pointer glides across a chart, the crosshair and * tooltip follow it day by day: one captured frame per hovered day, pointer positions in boxes.json). swap: the * dissolve from group 1 to group 2 (seconds into the scene). cam: one push, f = focus point (fraction of the 1280x800 - * frame), z = zoom. callouts: one per scene, on a box captured from the app (boxes.json). */ + * frame), z = zoom. callouts: on boxes captured from the app (boxes.json), below the box (above: true, above it). + * show / hide: the window fades in / out ([at, seconds]); pointer: the mouse pointer glides onto a captured box and + * rests there (it never clicks). */ export const DESK = { + // the console plan panel (App info > Console steps > App Privacy > Plan) with the real plan, at its final button + consoles: { + groups: [{ frame: 'console-plan' }], + show: CONSOLES_SWAP, + cam: { from: { f: [0.5, 0.56], z: 1.18 }, to: { f: [0.5, 0.56], z: 1.18 } }, + callouts: [{ at: 3.15, until: 5.3, target: 'cpFinal', text: 'Publish stays yours: it stops there', green: true }], + }, portfolio: { groups: [{ frame: 'portfolio-apps' }], cam: { from: { f: [0.5, 0.42], z: 1.0 }, to: { f: [0.6, 0.6], z: 1.14 } }, @@ -227,15 +265,28 @@ export const DESK = { cam: { from: { f: [0.55, 0.4], z: 1.0 }, to: { f: [0.72, 0.42], z: 1.14 } }, callouts: [{ at: 1.2, until: 5, target: 'priceGap', text: 'Price gap between the stores, per country' }], }, - // v5's desktop scene, unchanged: the release timeline, then Store readiness (validate): no blockers - desktop: { - groups: [{ frame: 'releases' }, { frame: 'ready' }], - swap: [2.1, 2.6], - cam: { from: { f: [0.56, 0.42], z: 1.0 }, to: { f: [0.8, 0.4], z: 1.22 } }, + // Releases > Send for review, back from App Review (UNRESOLVED_ISSUES): the rejected item, build 52 picked, Resubmit + resubmit: { + groups: [{ frame: 'resubmit' }], + cam: { from: { f: [0.45, 0.45], z: 1.0 }, to: { f: [0.3, 0.62], z: 1.18 } }, callouts: [ - { at: 0.7, until: 2.0, target: 'timeline', text: 'App Store + Google Play, one track' }, - { at: 2.9, until: 5, target: 'readyEmpty', text: 'Validate: 0 blockers on both', green: true }, + { at: 0.6, until: 2.5, target: 'unresolvedItem', text: 'Back from App Review: what was rejected' }, + { at: 2.75, until: 5.3, target: 'resubmitBtn', text: 'Marks it resolved and resubmits' }, ], + pointer: { at: 2.2, d: 0.8, target: 'resubmitBtn' }, + }, + // Submit for review: the dry-run plan with "Continue" (the in-app step before the system dialog), then the new + // submission waiting for review; the window then gives way to the end card + submit: { + groups: [{ frame: 'submit-plan' }, { frame: 'submit-waiting' }], + swap: [1.55, 1.95], + hide: [3.0, 0.5], + cam: { from: { f: [0.5, 0.45], z: 1.0 }, to: { f: [0.5, 0.48], z: 1.1 } }, + callouts: [ + { at: 0.35, until: 1.5, target: 'oaPlan', text: 'Dry run first: nothing sent yet', above: true }, + { at: 2.05, until: 3.0, target: 'waitingItem', text: 'Waiting for review', green: true }, + ], + pointer: { at: 0.3, d: 0.85, target: 'oaContinue', until: 1.55 }, }, }; export const DESK_SCENES = Object.keys(DESK); @@ -243,8 +294,9 @@ export const DESK_SCENES = Object.keys(DESK); export const DESK_FRAMES = [...new Set(Object.values(DESK).flatMap(d => d.groups.map(g => g.frame)))]; export const DESK_SWEEPS = Object.values(DESK).flatMap(d => d.groups.filter(g => g.sweep).map(g => g.sweep.seq)); -// ---------- 9 submit + end card ---------- -export const SUBMIT = { hover: 1.0, logoAt: 2.0, logoIn: 0.6 }; +// ---------- 13 submit: end card ---------- +/** The end card comes in at logoAt (seconds into the submit scene), as the app window fades out (DESK.submit.hide). */ +export const SUBMIT = { logoAt: 3.0, logoIn: 0.6 }; export const URL = 'storebound.dev'; export const TAGLINE = 'Open source · CLI · Desktop app'; @@ -265,9 +317,10 @@ export function ticks() { ...DECL_LINES.map((_, i) => ({ t: at('forms') + DECL_AT + i * DECL_STEP, kind: 'tiny' })), { t: at('forms') + ASK_AT, kind: 'ask' }, ...DATA_SAFETY_ROWS.map((_, i) => ({ t: at('forms') + FORM_AT + i * FORM_STEP, kind: 'tiny' })), + ...CONSOLE_LINES.map(l => ({ t: at('consoles') + l.at, kind: l.final ? 'ask' : 'tiny' })), ...FINDINGS.map(f => ({ t: at('rejections') + f.at, kind: 'low' })), ...FIX_AT.map((t, i) => ({ t: at('rejections') + t, kind: 'good', step: i })), - ...DESK_SCENES.flatMap(id => DESK[id].callouts.map(c => ({ t: at(id) + c.at, kind: 'soft' }))), + ...DESK_SCENES.flatMap(id => DESK[id].callouts.map(c => (id === 'submit' && c.green ? { t: at(id) + c.at, kind: 'good', step: 2 } : { t: at(id) + c.at, kind: 'soft' }))), ]; } /** Seconds at which each typed character lands (soft key clicks). */ @@ -275,5 +328,6 @@ export function keyTimes() { const out = []; for (let i = 0; i < INIT_CMD.length; i++) out.push(scene('init').start + INIT_TYPE_AT + i / INIT_CPS); for (let i = 0; i < PROMPT.length; i++) out.push(scene('plan').start + PROMPT_AT + i / PROMPT_CPS); + for (let i = 0; i < CONSOLE_CMD.length; i += 3) out.push(scene('consoles').start + CONSOLE_TYPE_AT + i / CONSOLE_CPS); return out; } diff --git a/media/teaser/teaser.js b/media/teaser/teaser.js index 17d4108..ee63dc8 100644 --- a/media/teaser/teaser.js +++ b/media/teaser/teaser.js @@ -1,6 +1,7 @@ -// Teaser picture (v6): every element is set from the time t alone (window.teaser.seek(t)), so frames render +// Teaser picture (v7): every element is set from the time t alone (window.teaser.seek(t)), so frames render // deterministically, in any order. 16:9 only (1920x1080). URL: &t=12.5 holds a frame, &play=1 previews in real time. -import { APP_NAME, ASK_AT, CAMERA, CAP_DELAY, CAP_END, CAP_IN, CAP_OUT, CAPTIONS, captionTimes, COPY, COPY_AT, COPY_FILE, COPY_STEP, +import { APP_NAME, ASK_AT, CAMERA, CAP_DELAY, CAP_END, CAP_IN, CAP_OUT, CAPTIONS, captionTimes, CONSOLE_CMD, CONSOLE_CPS, CONSOLE_LINES, + CONSOLE_TYPE_AT, CONSOLES_PILL, CONSOLES_SWAP, COPY, COPY_AT, COPY_FILE, COPY_STEP, DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESK, DESK_SCENES, DISSOLVE, DURATION, FINDINGS, FIX_AT, FOLDER, FORM_AT, FORM_STEP, FORMAT, FORMS_PILL, FPS, INIT_CMD, INIT_CPS, INIT_LINES, INIT_OUT_AT, INIT_STEP, INIT_TYPE_AT, MAIL, MAIL_IN, PLAN_AT, PLAN_CMD, PLAN_CMD_AT, PLAN_LINES, PLAN_PILL, PLAN_STEP, PRODUCT_LINES, PRODUCTS_PILL, PROMPT, PROMPT_AT, PROMPT_CPS, @@ -264,7 +265,34 @@ function forms(t) { pillAt(formsPill, r, FORMS_PILL.at); } -// ---------- 7 rejections ---------- +// ---------- 7 consoles: the console plan in the terminal, then the app's plan panel (DESK.consoles) ---------- +const cpBody = $('#cpterm .body'), cpPill = $('#cp-pill'), cpTerm = $('#cpterm'); +const cpLine = l => { + if (l.final) return `${esc(l.text.replace(/: Publish$/, ':'))} Publish`; + if (l.head) return `${esc(l.text)}`; + if (/^Never press:/.test(l.text)) return esc(l.text).replace(/^(Never press:)/, '$1'); + return esc(l.text).replace(/^(\s+\d+\.)/, '$1').replace(/^(\s+)\[ok\]/, '$1[ok]') + .replace(/^(\s+)\[ask\]/, '$1[ask]').replace(/(\[click: [^\]]+\])/, '$1'); +}; +cpBody.innerHTML = `${CONSOLE_LINES.map(l => `${cpLine(l)}`).join('')}`; +const cpLns = [...cpBody.querySelectorAll('.ln')], cpGlow = $('#cpterm .fin .glow'); +cpPill.innerHTML = bold(CONSOLES_PILL.text); +function consoles(t) { + const s = scene('consoles'); if (t < s.start - 1 || t > s.end + 1) return; + const r = t - s.start; + const out = 1 - eio(prog(r, CONSOLES_SWAP[0], CONSOLES_SWAP[1])); + appear(cpTerm, r, 0.0, 0.35, 14); + set(cpTerm, 'opacity', String(eoc(prog(r, 0, 0.35)) * out)); + const n = clamp(Math.floor((r - CONSOLE_TYPE_AT) * CONSOLE_CPS), 0, CONSOLE_CMD.length); + html(cpLns[0], `$ ${esc(CONSOLE_CMD.slice(0, n))}${n < CONSOLE_CMD.length ? '' : ''}`); + set(cpLns[0], 'opacity', '1'); + cpLns.slice(1).forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at, 0.25, 6)); + set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.15, 0.4)))); + pillAt(cpPill, r, CONSOLES_PILL.at); + set(cpPill, 'opacity', String(eoc(prog(r, CONSOLES_PILL.at, 0.4)) * out)); +} + +// ---------- 8 rejections ---------- const scanBody = $('#scanterm .body'); scanBody.innerHTML = `$ ${esc(SCAN_CMD)}` + `${esc(RISKS_LINE).replace(/(2 blocker, 3 high)/, '$1')}` @@ -292,7 +320,7 @@ function rejections(t) { appear(scanLns[3], r, RESCAN_AT + 0.35, 0.3, 6); } -// ---------- 8-11 desktop: real frames in a window, one push per scene, one callout, hover sweeps on the charts ---------- +// ---------- 7, 9-13 desktop: real frames in a window, one push per scene, callouts, hover sweeps, a resting pointer ---------- const BOXES = await fetch('out/desktop/boxes.json').then(r => { if (!r.ok) throw new Error('out/desktop/boxes.json missing (bun media/teaser/desktop.ts)'); return r.json(); }); const WIN = { x: 370, y: 236, w: 1180, bar: 40 }; const VIEW = { w: WIN.w, h: WIN.w * 800 / 1280 }; @@ -307,14 +335,17 @@ const sweepOf = g => { const desks = DESK_SCENES.map(id => { const d = DESK[id], cam = $(`#s-${id} > .cam`); const groups = d.groups.map(g => ({ g, sweep: sweepOf(g) })); - cam.innerHTML = `
+ // the window lives in the scene's .dhost (the consoles scene has its terminal next to it), else in the camera + const host = cam.querySelector('.dhost') ?? cam.appendChild(Object.assign(document.createElement('div'), { className: 'dhost' })); + host.innerHTML = `
storebound
${groups.map((x, gi) => `
${(x.sweep ?? []).map(f => ``).join('')}
`).join('')}
${d.callouts.map(c => `
${esc(c.text)}
`).join('')} - ${groups.some(x => x.sweep) ? '' : ''}`; - const grpEls = [...cam.querySelectorAll('.grp')]; - return { id, d, groups, cam2: cam.querySelector('.cam2'), grpEls, imgs: grpEls.map(g => [...g.querySelectorAll('img')]), - rings: [...cam.querySelectorAll('.ring')], callouts: [...cam.querySelectorAll('.callout')], cur: cam.querySelector('.dcur') }; + ${groups.some(x => x.sweep) || d.pointer ? '' : ''}`; + if (d.pointer && !BOXES[d.pointer.target]) throw new Error(`boxes.json: no "${d.pointer.target}" (bun media/teaser/desktop.ts)`); + const grpEls = [...host.querySelectorAll('.grp')]; + return { id, d, host, groups, cam2: host.querySelector('.cam2'), grpEls, imgs: grpEls.map(g => [...g.querySelectorAll('img')]), + rings: [...host.querySelectorAll('.ring')], callouts: [...host.querySelectorAll('.callout')], cur: host.querySelector('.dcur') }; }); function deskView(dk, r) { const s = scene(dk.id), len = s.end - s.start; @@ -337,6 +368,10 @@ function sweepAt(sw, f, r) { function deskScene(dk, t) { const s = scene(dk.id); if (t < s.start - 1 || t > s.end + 1) return; const r = t - s.start, d = dk.d; + // the window fades in (show) and out (hide), with its callouts and pointer + const shown = (d.show ? eio(prog(r, d.show[0], d.show[1])) : 1) * (d.hide ? 1 - eio(prog(r, d.hide[0], d.hide[1])) : 1); + set(dk.host, 'opacity', String(shown)); + set(dk.host, 'visibility', shown > 0 ? 'visible' : 'hidden'); const c = deskView(dk, r); set(dk.cam2, 'transform', `translate(${c.ox.toFixed(2)}px, ${c.oy.toFixed(2)}px) scale(${c.S.toFixed(5)})`); const sw = d.swap ? eio(prog(r, d.swap[0], d.swap[1] - d.swap[0])) : 0; @@ -351,6 +386,14 @@ function deskScene(dk, t) { const op = prog(r, x.g.sweep.at - 0.35, 0.3) * vis; if (op > 0 && (!cur || op > cur.op)) cur = { ...st, op }; }); + // a resting pointer: glides in from below right onto its target and stays there (it never clicks) + if (d.pointer) { + const pt = d.pointer, b = BOXES[pt.target]; + const tx = b.x + b.w * 0.62, ty = b.y + b.h * 0.64; + const p = eio(prog(r, pt.at, pt.d)); + const op = prog(r, pt.at - 0.25, 0.25) * (pt.until ? 1 - prog(r, pt.until - 0.1, 0.25) : 1); + if (op > 0) cur = { px: lerp(Math.min(tx + 0.16, 0.97), tx, p), py: lerp(Math.min(ty + 0.2, 0.97), ty, p), op }; + } if (dk.cur) { set(dk.cur, 'opacity', String(cur ? cur.op : 0)); if (cur) { @@ -369,7 +412,7 @@ function deskScene(dk, t) { set(ring, 'width', `${(x1 - x0).toFixed(1)}px`); set(ring, 'height', `${(y1 - y0).toFixed(1)}px`); const cw = call.offsetWidth; const cx = clamp((x0 + x1) / 2, 60 + cw / 2, W - 60 - cw / 2); - const below = y1 + 90 < WIN.y + WIN.h; + const below = !co.above && y1 + 90 < WIN.y + WIN.h; set(call, 'opacity', String(op)); set(call, 'left', `${cx.toFixed(1)}px`); set(call, 'top', `${(below ? y1 + 24 : y0 - 24).toFixed(1)}px`); @@ -378,25 +421,14 @@ function deskScene(dk, t) { } const desktop = t => desks.forEach(dk => deskScene(dk, t)); -// ---------- 9 submit + end card ---------- -const sbtn = $('#sbtn'), shov = $('#sbtn .hov'), brand = $('#brand'), cursorEl = $('#cursor'); +// ---------- 13 submit: the end card (the app window is DESK.submit; it fades out as the card comes in) ---------- +const brand = $('#brand'); brand.querySelector('.url').textContent = URL; brand.querySelector('.plat').textContent = TAGLINE; function submit(t) { const s = scene('submit'); const r = t - s.start; - if (t < s.start - 1) { set(cursorEl, 'opacity', '0'); return; } - const hov = prog(r, SUBMIT.hover, 0.25); - const out = prog(r, SUBMIT.logoAt, 0.6); - set(sbtn, 'opacity', String(eoc(prog(r, 0.0, 0.4)) * (1 - eio(out)))); - set(sbtn, 'transform', `translate(-50%, 0) scale(${(lerp(0.96, 1, eoc(prog(r, 0, 0.5))) * (1 + 0.02 * hov)).toFixed(4)})`); - set(shov, 'opacity', String(hov)); - // the cursor glides in and rests on the button: it never clicks - const btn = sbtn.getBoundingClientRect(); - const tx = btn.left + btn.width * 0.64, ty = btn.top + btn.height * 0.66; - const p = eio(prog(r, 0.3, SUBMIT.hover - 0.3)); - set(cursorEl, 'opacity', String(prog(r, 0.3, 0.3) * (1 - eio(out)))); - set(cursorEl, 'transform', `translate(${(lerp(W * 0.86, tx, p) - 10).toFixed(1)}px, ${(lerp(H * 0.95, ty, p) - 10).toFixed(1)}px)`); + if (t < s.start - 1) return; [...brand.children].forEach((el, i) => { const q2 = prog(r, SUBMIT.logoAt + 0.2 + i * 0.18, SUBMIT.logoIn); set(el, 'opacity', String(eoc(q2))); @@ -413,6 +445,7 @@ function seek(t) { store(t); products(t); forms(t); + consoles(t); rejections(t); desktop(t); submit(t); @@ -423,7 +456,17 @@ const ready = (async () => { await document.fonts.ready; const imgs = [...document.images]; const bgs = [...document.querySelectorAll('.tile')].map(el => Object.assign(new Image(), { src: el.style.backgroundImage.replace(/^url\("?|"?\)$/g, '') })); - await Promise.all([...imgs, ...bgs].map(im => im.decode().catch(() => { throw new Error(`image failed: ${im.src}`); }))); + // a few at a time, each retried: Chrome refuses some of ~90 parallel 2560x1600 decodes under load (EncodingError) + const all = [...imgs, ...bgs]; + const decode = async im => { + for (let i = 0; ; i++) { + try { await im.decode(); return; } catch (e) { + if (i >= 3) throw new Error(`image failed: ${im.src} (${e})`); + await new Promise(r => setTimeout(r, 200 * (i + 1))); + } + } + }; + for (let i = 0; i < all.length; i += 8) await Promise.all(all.slice(i, i + 8).map(decode)); seek(0); })(); window.teaser = { ready, seek, DURATION, FPS, W, H, captionTimes }; diff --git a/tests/teaser.test.ts b/tests/teaser.test.ts index 81219db..ea76189 100644 --- a/tests/teaser.test.ts +++ b/tests/teaser.test.ts @@ -1,6 +1,6 @@ -// The teaser video (media/teaser, v6): every CLI line it shows is real output of the command it names, every desktop -// frame is a real capture of the app (desktop.ts), each scene holds 4-5 s, each caption is fully on screen for at least -// 3 s, the whole cut is 50-60 s, and it is 16:9 only. +// The teaser video (media/teaser, v7): every CLI line it shows is real output of the command it names, every desktop +// frame is a real capture of the app (desktop.ts), each scene holds 4-5 s (a whole number of beats), each caption is +// fully on screen for at least 3 s, the whole cut is 60-65 s, and it is 16:9 only. import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { generateKeyPairSync } from 'node:crypto'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -25,9 +25,13 @@ interface Teaser { COPY_FILE: string; COPY: { lang: string; title: string[] }[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; SHOTS_PILL: string; PRODUCT_LINES: { text: string; cmd?: boolean }[]; PRODUCTS_PILL: { text: string }; DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string; DECL_TOTAL: number; + CONSOLE_CMD: string; CONSOLE_LINES: { text: string; head?: boolean; final?: boolean }[]; CONSOLE_STEPS: number; CONSOLE_ANSWERS: number; + CONSOLES_PILL: { text: string }; CONSOLES_SWAP: number[]; SCAN_CMD: string; RISKS_LINE: string; RESCAN_LINE: string; FINDINGS: { sev: string; guideline: string; title: string; where: string; why: string }[]; - DESK: Record; + DESK: Record; + SUBMIT: { logoAt: number }; DESK_SCENES: string[]; DESK_FRAMES: string[]; DESK_SWEEPS: string[]; } const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser; @@ -100,6 +104,27 @@ describe('teaser: every CLI line is real output', () => { } finally { writeFileSync(cfg, src); } }); + test('consoles: `storebound console plan apple-app-privacy` (offline): steps, answer sheet, never-press, the owner\'s Publish', () => { + expect(T.CONSOLE_CMD).toBe('storebound console plan apple-app-privacy'); + const r = run(T.CONSOLE_CMD.split(' ').slice(1), dir); + expect(r.code).toBe(0); + const out = lines(r.out); + for (const l of T.CONSOLE_LINES) expect(out, l.text).toContain(l.text); + // the lines keep the order of the real plan + const idx = T.CONSOLE_LINES.map(l => out.indexOf(l.text)); + expect([...idx].sort((a, b) => a - b)).toEqual(idx); + // the pill's counts: the numbered steps and the answer-sheet rows of the whole plan + const steps = out.slice(out.indexOf('Steps:'), out.indexOf('Answer sheet:')).filter(l => /^\s+\d+\. /.test(l)).length; + const answers = out.filter(l => /^ {2}\[(ok|ask|check|agree)\] /.test(l)).length; + expect(steps).toBe(T.CONSOLE_STEPS); + expect(answers).toBe(T.CONSOLE_ANSWERS); + expect(T.CONSOLES_PILL.text).toBe(`*${steps}* steps · *${answers}* answers · stops before *Publish*`); + // the final button stays the owner's, and the never-press list holds it + const fin = T.CONSOLE_LINES.find(l => l.final)!; + expect(fin.text).toBe("Final button (the owner's): Publish"); + expect(T.CONSOLE_LINES.find(l => l.text.startsWith('Never press:'))!.text).toContain('"Publish"'); + }); + test('forms: the Data safety answer count of `storebound check`', () => { expect(run(['check'], dir).out).toContain(`data safety: ${T.DECL_TOTAL} CSV rows`); }); @@ -159,16 +184,20 @@ describe('teaser: timing and format', () => { expect(render).not.toMatch(/1080\s*x\s*1920|w: 1080, h: 1920|vertical/i); expect(render).toContain('scale=1280:720'); }); - test('scenes are contiguous, 4-5 s each, 50-60 s in total', () => { + test('scenes are contiguous, 4-5 s each (whole half seconds: each cut on a beat of music.js), 60-65 s in total', () => { expect(T.SCENES[0]!.start).toBe(0); for (let i = 1; i < T.SCENES.length; i++) expect(T.SCENES[i]!.start).toBe(T.SCENES[i - 1]!.end); for (const s of T.SCENES) { expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(4); expect(s.end - s.start, s.id).toBeLessThanOrEqual(5); + expect(Number.isInteger(s.start * 2), s.id).toBe(true); } expect(T.DURATION).toBe(T.SCENES.at(-1)!.end); - expect(T.DURATION).toBeGreaterThanOrEqual(50); - expect(T.DURATION).toBeLessThanOrEqual(60); + expect(T.DURATION).toBeGreaterThanOrEqual(60); + expect(T.DURATION).toBeLessThanOrEqual(65); + const music = readFileSync(join(ROOT, 'media/teaser/music.js'), 'utf8'); + expect(music).toContain('export const STEP = 0.5;'); + for (const s of T.SCENES) expect(music, s.id).toMatch(new RegExp(`SCENE_CHORD = \\{[^}]*\\b${s.id}: '`)); }); test('one caption per scene, fully on screen for at least 3 s, inside its scene', () => { const c = T.captionTimes(); @@ -186,7 +215,8 @@ describe('teaser: timing and format', () => { }); }); -// The desktop sequence: four scenes of the real app (fixture mode), captured by media/teaser/desktop.ts. The frames are +// The desktop sequence: the real app (fixture mode) in the consoles scene and the last five, captured by +// media/teaser/desktop.ts. The frames are // gitignored output; the file checks run wherever they were captured (render.ts captures them before every render). const DESK_DIR = join(ROOT, 'media/teaser/out/desktop'); const captured = existsSync(join(DESK_DIR, 'boxes.json')); @@ -203,15 +233,46 @@ function jpegSize(buf: Buffer): { w: number; h: number } | null { } describe('teaser: the desktop scenes are real captures of the app', () => { - test('four desktop scenes in a row before the submit card, the last one is v5\'s (timeline, then 0 blockers)', () => { - expect(T.DESK_SCENES).toEqual(['portfolio', 'charts', 'catalog', 'desktop']); + test('the app in the consoles scene and the last five: the plan panel, portfolio, charts, prices, resubmit, submit', () => { + expect(T.DESK_SCENES).toEqual(['consoles', 'portfolio', 'charts', 'catalog', 'resubmit', 'submit']); const ids = T.SCENES.map(s => s.id); - expect(ids.slice(-5)).toEqual([...T.DESK_SCENES, 'submit']); - expect(T.DESK.desktop!.groups.map(g => g.frame)).toEqual(['releases', 'ready']); - expect(T.CAPTIONS.find(c => c.id === 'desktop')!.lines.join(' ').replace(/\*/g, '')).toBe('Both stores, one view. Ready for review.'); - for (const id of T.DESK_SCENES) expect(T.DESK[id]!.callouts.length, id).toBe(1 + (id === 'desktop' ? 1 : 0)); + expect(ids).toEqual(['hook', 'init', 'plan', 'store', 'products', 'forms', 'consoles', 'rejections', 'portfolio', 'charts', 'catalog', 'resubmit', 'submit']); + const cap = (id: string) => T.CAPTIONS.find(c => c.id === id)!.lines.join(' ').replace(/\*/g, ''); + expect(cap('consoles')).toBe('No API? Your AI fills the console. It stops at Publish.'); + expect(cap('resubmit')).toBe('Rejected? Fix it, resubmit in one click.'); + expect(cap('submit')).toBe('You press Submit. That\'s it.'); + // the terminal gives way to the app's plan panel, which comes in as the terminal goes + expect(T.DESK.consoles!.groups.map(g => g.frame)).toEqual(['console-plan']); + expect(T.DESK.consoles!.show).toEqual(T.CONSOLES_SWAP); + expect(T.DESK.resubmit!.groups.map(g => g.frame)).toEqual(['resubmit']); + // submit: the dry-run plan with Continue, then waiting for review; the window fades out as the end card comes in + expect(T.DESK.submit!.groups.map(g => g.frame)).toEqual(['submit-plan', 'submit-waiting']); + expect(T.DESK.submit!.hide![0]).toBe(T.SUBMIT.logoAt); + expect(T.DESK.submit!.pointer!.target).toBe('oaContinue'); + expect(T.DESK.resubmit!.pointer!.target).toBe('resubmitBtn'); + // the v6 desktop scene (timeline, readiness) is gone: v7 is 62 s with resubmit and submit + expect(ids).not.toContain('desktop'); + for (const id of T.DESK_SCENES) { + const s = T.SCENES.find(x => x.id === id)!; + expect(T.DESK[id]!.callouts.length, id).toBeGreaterThanOrEqual(1); + expect(T.DESK[id]!.callouts.length, id).toBeLessThanOrEqual(2); + // callouts follow one another, never two at once, and each holds long enough to read + const c = T.DESK[id]!.callouts; + for (let i = 0; i < c.length; i++) { + expect(Math.min(c[i]!.until, s.end - s.start) - c[i]!.at, `${id} ${c[i]!.text}`).toBeGreaterThanOrEqual(0.9); + if (i) expect(c[i]!.at, id).toBeGreaterThanOrEqual(c[i - 1]!.until); + } + } expect(T.DESK_SWEEPS).toEqual(['growth', 'health']); }); + test('the console plan panel shows the real plan (desktop.ts serves `console plan apple-app-privacy --json`)', () => { + const src = readFileSync(join(ROOT, 'media/teaser/desktop.ts'), 'utf8'); + expect(src).toContain("realConsolePlan('apple-app-privacy')"); + expect(src).toContain("['console', 'plan', form, '--json']"); + // never a drawn macOS dialog: the submit scene shows the app's own plan panel before the system dialog + const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8'); + expect(html).not.toMatch(/Submit for Review|id="sbtn"/); + }); test('every frame a scene names is one desktop.ts captures from the app (no mock frames)', () => { const src = readFileSync(join(ROOT, 'media/teaser/desktop.ts'), 'utf8'); expect(src).toContain("VITE_FIXTURES: '1'"); @@ -244,5 +305,6 @@ describe('teaser: the desktop scenes are real captures of the app', () => { // every sweep frame differs from the others (the tooltip moved): no copied frames expect(sizes.size).toBeGreaterThan(files.length * 0.9); for (const id of T.DESK_SCENES) for (const c of T.DESK[id]!.callouts) expect(boxes[c.target], c.target).toBeDefined(); + for (const id of T.DESK_SCENES) if (T.DESK[id]!.pointer) expect(boxes[T.DESK[id]!.pointer!.target], id).toBeDefined(); }); });