teaser v8: a 58 s story instead of a feature list (the game is done; the stores pile up; one missing line; hand it to your AI; a montage of builds, screenshots, products, forms, consoles; the scan turns green; one button is yours; Waiting for review; the end card), one short line per scene, cuts on the 120 BPM beat; music rewritten for the arc (calm, tension, near silence, a driving build, a stop, a resolve), mastered with one gain instead of dynamic loudnorm; the pile shows real console plan, Data safety CSV and price sheet lines, the builds beat real apple / play build dry runs; desktop.ts captures submit-ready (Send for review before the press); tests check every line, the beat-aligned cuts and the caption times; two unused teaser icons dropped
This commit is contained in:
parent
d5ffc159c0
commit
0b6c8cd7a0
11 changed files with 1117 additions and 1041 deletions
|
|
@ -1,76 +1,74 @@
|
|||
# storebound teaser (62 s, 16:9)
|
||||
# storebound teaser (v8, 58 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.
|
||||
A short story, not a feature list: the game is done, the stores pile up work, one missing line sends you back to the
|
||||
start, so you hand it all to your AI, it does the work, and one button stays yours. Little text (one short line per
|
||||
scene at most, big and calm), real product footage only, and the music carries the arc: calm, tension, near silence,
|
||||
a driving build, a stop, a resolve. 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 (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.
|
||||
120 BPM: every cut lands on a beat (whole half seconds). The story scenes dissolve and drift slowly; the montage cuts
|
||||
hard on the beat with one quick push each.
|
||||
|
||||
| s | shows | caption |
|
||||
|---|---|---|
|
||||
| 0-4.5 | the demo game's icon, "Done"; the stores' to-do list line by line; one rejection mail (Guideline 2.1) | Your app is done. The stores are not. |
|
||||
| 4.5-9 | an empty folder; `bunx storebound init` is typed; the files it creates list in | storebound takes it from an empty folder. |
|
||||
| 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; 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. |
|
||||
| s | shows | text | music |
|
||||
|---|---|---|---|
|
||||
| 0-5 | the demo game's own screens (its store captures), floating, a slow drift | The game is done. | calm: a warm D major pad, a slow pluck |
|
||||
| 5-11 | store work lands card after card, faster and faster: the App Privacy answer sheet and console steps, the Data safety CSV (768 rows), the screenshot sizes, the price sheet | Then come the stores. | tension: B minor, a growing low pulse, a ticking clock, a riser; a thud per card |
|
||||
| 11-15 | one real scan finding (BLOCKER 5.1.1(v), at Planner/LoginView.swift:11) grows until it fills the frame; the picture goes dark | One missing line, and you start over. | a low boom, a dark drone sinking to nothing |
|
||||
| 15-19 | in the dark, one request is typed to the AI agent: "Get this game ready for review on both stores." | So I handed it all to my AI. | near silence: room tone, key clicks, a swell into the drop |
|
||||
| 19-23 | `apple build --dry` and `play build --dry`: build 52, versionCode 52 | Builds. | the drop: kick, bass, arpeggio |
|
||||
| 23-27 | the demo game's renders: 7 store sizes x 4 languages | Screenshots, every size. | + hats |
|
||||
| 27-30.5 | `play products --dry` (174 regions), `catalog diff` (175 / 174 region prices, 0 mismatches) | Products, 175 countries. | + claps |
|
||||
| 30.5-34 | the scan's declarations proposal (one "ask owner" line), the Data safety rows filling, 768 answers | Forms. | + sixteenths |
|
||||
| 34-37 | `console plan apple-app-privacy`, then the desktop app's plan panel with that real plan, at the owner's Publish | Consoles. | + a brighter lead |
|
||||
| 37-42 | `storebound scan`: five findings turn green one per beat, the re-scan is clean (0 blocker, 0 high) | Nothing left for a reviewer to catch. | the drive peaks, a chime per fix |
|
||||
| 42-48 | the desktop app's Releases › Send for review (build 52 picked); the camera settles on "Submit for review", the pointer rests on it | One button is yours. | the music stops; a quiet low pulse |
|
||||
| 48-53 | the click; the same card shows the new submission "Waiting for review" (the app's own state) | (none) | the resolve: a wide D major chord, bells |
|
||||
| 53-58 | the storebound mark (brand/logo-mark-256.png) | From an empty folder to Submit. · Open source · storebound.dev | the chord holds, fades |
|
||||
|
||||
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.
|
||||
The demo game has 4 languages (en, tr, ja, ar), so the screenshot beat says "every size", not a language count.
|
||||
The video shows no drawn system dialog and no mail: between the click and "Waiting for review" the real app shows its
|
||||
own dry-run plan and then the system's confirm dialog, which the story skips.
|
||||
|
||||
| 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 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 |
|
||||
| `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` (ring and pointer targets) -> `out/desktop/`. The console plan panel gets the real `storebound console plan apple-app-privacy --json` of a fresh `storebound init` folder; the send-for-review frames press "Read state" and pick build 52 as the owner would (`submit-ready`), and the "Waiting for review" frame serves the fixture's apple status with the new submission waiting. It also captures the frames the site uses (`site-shots.ts`) |
|
||||
| `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 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 |
|
||||
| `music.js` | music and sound design, one section per part of the arc (see the table) |
|
||||
| `render.ts` | frames at 60 fps blended to 30 fps -> ffmpeg (H.264 high, yuv420p); sound: one gain to about -16 LUFS with the true peak at or below -1.5 dBTP (no dynamic normalization, so the quiet parts stay quiet), 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) |
|
||||
|
||||
Every CLI line in the video is real output, and `tests/teaser.test.ts` runs each command and checks every line (the
|
||||
clock prefix and the "(0s)" timer left out):
|
||||
clock prefix and the "(0s)" timer left out), in a fresh `storebound init` folder with two products:
|
||||
|
||||
- `storebound init` in an empty temp folder.
|
||||
- `storebound release plan 1.0.0`, `play products --dry` and `catalog diff` in that folder, with the stores answered by
|
||||
`tests/helpers/teaser-store-mock.ts` (preloaded through a `bunfig.toml` in the folder, so the children `release plan`
|
||||
starts use it too; nothing reaches the network): a brand-new app record with nothing on it for the plan and the dry
|
||||
run, then the two products on both stores for the diff. Regions: the App Store's 175 storefronts, Google Play 174
|
||||
(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.
|
||||
- `storebound console plan apple-app-privacy` (offline): the answer sheet and step lines of the pile, the plan lines of
|
||||
the consoles beat, the never-press list and the owner's final button.
|
||||
- `storebound play data-safety --dry` (the CSV lines), `storebound check` (768 rows), `storebound prices ppp --offline
|
||||
--dry` (the price rows).
|
||||
- `storebound apple build --dry` and `storebound play build --dry` in a minimal Capacitor project.
|
||||
- `storebound play products --dry` and `catalog diff`, with the stores answered by
|
||||
`tests/helpers/teaser-store-mock.ts` (preloaded through a `bunfig.toml` in the folder; nothing reaches the network).
|
||||
- `storebound scan` of `tests/fixtures/scan/teaser` (the 5.1.1(v) finding, all five findings, the declarations
|
||||
proposal) and of `teaser-fixed` after the fixes.
|
||||
|
||||
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 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).
|
||||
The test also checks the timing (every cut on a half-second beat, scenes 3-6 s, the montage at 19-37 s speeding up with
|
||||
cuts rather than dissolves, captions of at most 8 words fully on screen at least 2.5 s inside their scene, 56-62 s in
|
||||
total, 16:9 only), that the game screens and screenshot tiles are the demo game's own, and that every desktop frame a
|
||||
scene names is one `desktop.ts` captures (where they were captured: each file there, 2560x1600 JPEG).
|
||||
|
||||
## Render
|
||||
|
||||
Needs Bun, ffmpeg, Playwright (repo devDependency; Playwright's Chromium or an installed Google Chrome) and the demo
|
||||
game's store renders:
|
||||
game's store captures and 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 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,30.5,57.8 # PNG stills in out/stills for a quick layout check
|
||||
bun media/teaser/render.ts --stills 2.5,13.5,46 # PNG stills in out/stills for a quick layout check
|
||||
bun media/teaser/render.ts --sound # the music only (out/music.wav)
|
||||
bun media/teaser/render.ts --serve --port 8750 # live preview: /index.html?play=1 (or ?t=12.5)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
// 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-ready.jpg Prism Puddle's Releases > Send for review: build 52 picked, before "Submit for review" is pressed
|
||||
// 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)
|
||||
|
|
@ -288,7 +289,12 @@ APPLE_STATUS_READY.emptyDrafts = [];\n`;
|
|||
// 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.waitForTimeout(300);
|
||||
// before the press: build 52 picked, "Submit for review" the one button left (the owner's)
|
||||
const submitBtn = c.page.locator('[data-section="submit"] button', { hasText: 'Submit for review' }).first();
|
||||
await box(c.page, 'submitBtn', submitBtn);
|
||||
await shot(c.page, 'submit-ready');
|
||||
await submitBtn.click();
|
||||
await c.page.waitForSelector('.oa-panel');
|
||||
await c.page.waitForTimeout(800);
|
||||
await clearToasts(c.page);
|
||||
|
|
@ -312,7 +318,7 @@ APPLE_STATUS_READY.emptyDrafts = [];\n`;
|
|||
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 = 14 + Object.values(sweeps).reduce((k, f) => k + f.length, 0);
|
||||
const n = 15 + 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();
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M208,28H48A20,20,0,0,0,28,48V208a20,20,0,0,0,20,20H208a20,20,0,0,0,20-20V48A20,20,0,0,0,208,28Zm-4,24v92H179.31a19.86,19.86,0,0,0-14.14,5.86L147,168H109L90.83,149.86A19.86,19.86,0,0,0,76.69,144H52V52ZM52,204V168H75l18.14,18.14A19.86,19.86,0,0,0,107.31,192h41.38a19.86,19.86,0,0,0,14.14-5.86L181,168h23v36Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 405 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm37.66,130.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 341 B |
|
|
@ -22,18 +22,17 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
|
|||
-webkit-font-smoothing: antialiased; }
|
||||
#stage > * { position: absolute; }
|
||||
.scene { inset: 0; opacity: 0; visibility: hidden; overflow: hidden; }
|
||||
.scene > .cam { position: absolute; inset: 0; transform-origin: 50% 60%; }
|
||||
.scene > .cam { position: absolute; inset: 0; transform-origin: 50% 55%; }
|
||||
.abs { position: absolute; }
|
||||
|
||||
/* icons: Phosphor files in icons/ (tools/site-icons.ts), shown as masks in the element's colour */
|
||||
.pi { display: inline-block; width: 1em; height: 1em; background: currentColor; vertical-align: -0.14em; flex: none;
|
||||
-webkit-mask: var(--pi) center / contain no-repeat; mask: var(--pi) center / contain no-repeat; }
|
||||
.i-tray { --pi: url(icons/tray-bold.svg); } .i-x { --pi: url(icons/x-circle-fill.svg); }
|
||||
.i-ok { --pi: url(icons/check-circle-fill.svg); } .i-check { --pi: url(icons/check-bold.svg); }
|
||||
.i-stop { --pi: url(icons/warning-octagon-fill.svg); } .i-send { --pi: url(icons/paper-plane-tilt-fill.svg); }
|
||||
.i-term { --pi: url(icons/terminal-window-bold.svg); } .i-cursor { --pi: url(icons/navigation-arrow-fill.svg); }
|
||||
|
||||
/* ---------- background: a calm, slowly drifting glow ---------- */
|
||||
/* ---------- background: a calm, slowly drifting glow; the veil darkens the picture (rejection, request) ---------- */
|
||||
#bg { inset: 0; }
|
||||
#bg .glow { position: absolute; border-radius: 50%; }
|
||||
#bg .g1 { width: 1400px; height: 1400px; background: radial-gradient(circle, rgba(91,92,240,.30), rgba(91,92,240,0) 62%); }
|
||||
|
|
@ -41,147 +40,111 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
|
|||
#bg .grid { position: absolute; inset: -96px; opacity: .45;
|
||||
background-image: linear-gradient(rgba(148,163,184,.05) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,.05) 1px, transparent 1px);
|
||||
background-size: 96px 96px; }
|
||||
#veil { inset: 0; pointer-events: none; opacity: 0; background: #020308; }
|
||||
#vig { inset: 0; pointer-events: none; z-index: 40; background: radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0) 60%, rgba(0,0,0,.45) 100%); }
|
||||
|
||||
/* ---------- caption: top, big, clean ---------- */
|
||||
#capband { left: 0; right: 0; top: 0; height: 250px; z-index: 45; pointer-events: none;
|
||||
background: linear-gradient(rgba(7,11,22,.9) 0%, rgba(7,11,22,.7) 60%, rgba(7,11,22,0) 100%); }
|
||||
#cap { z-index: 50; left: 100px; right: 100px; top: 58px; text-align: center; font-family: var(--display); font-weight: 750;
|
||||
font-size: 72px; letter-spacing: -0.025em; line-height: 1.12; color: #fff; text-shadow: 0 4px 24px rgba(0,0,0,.5); }
|
||||
#cap .line { display: block; white-space: nowrap; }
|
||||
/* ---------- captions: the story line at the top (big, calm); the montage word at the bottom left edge ---------- */
|
||||
#capband { left: 0; right: 0; top: 0; height: 260px; z-index: 45; pointer-events: none; opacity: 0;
|
||||
background: linear-gradient(rgba(7,11,22,.88) 0%, rgba(7,11,22,.65) 60%, rgba(7,11,22,0) 100%); }
|
||||
#cap { z-index: 50; left: 80px; right: 80px; top: 64px; text-align: center; font-family: var(--display); font-weight: 750;
|
||||
font-size: 88px; letter-spacing: -0.03em; line-height: 1.1; color: #fff; white-space: nowrap; text-shadow: 0 4px 30px rgba(0,0,0,.55); opacity: 0; }
|
||||
#cap .hl { color: var(--accent); } #cap.red .hl { color: var(--red); } #cap.green .hl { color: var(--green); }
|
||||
#edgeband { left: 0; right: 0; bottom: 0; height: 300px; z-index: 45; pointer-events: none; opacity: 0;
|
||||
background: linear-gradient(rgba(7,11,22,0) 0%, rgba(7,11,22,.75) 55%, rgba(7,11,22,.92) 100%); }
|
||||
#edge { z-index: 50; left: 96px; bottom: 70px; font-family: var(--display); font-weight: 850; font-size: 108px; letter-spacing: -0.035em;
|
||||
line-height: 1; color: #fff; white-space: nowrap; text-shadow: 0 6px 30px rgba(0,0,0,.6); opacity: 0; }
|
||||
|
||||
/* ---------- shared: cards, terminal, pills ---------- */
|
||||
/* ---------- shared: cards, terminal ---------- */
|
||||
.card { position: absolute; background: var(--surface); border: 2px solid var(--line); border-radius: 24px; overflow: hidden;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,.45); }
|
||||
.card .ch { display: flex; align-items: center; gap: 14px; padding: 18px 26px; border-bottom: 2px solid var(--line);
|
||||
font: 700 26px/1.2 var(--mono); color: #fff; background: rgba(255,255,255,.02); }
|
||||
font: 700 26px/1.2 var(--mono); color: #fff; background: rgba(255,255,255,.02); white-space: nowrap; }
|
||||
.card .ch .dots { display: flex; gap: 9px; margin-right: 8px; } .card .ch .dots i { width: 14px; height: 14px; border-radius: 50%; background: #3a4255; display: block; }
|
||||
.card .ch .sub { margin-left: auto; font: 600 22px/1 var(--text); color: var(--muted); }
|
||||
.term { font-family: var(--mono); color: var(--ink-2); }
|
||||
.term .ln { display: block; white-space: pre; overflow: hidden; text-overflow: ellipsis; opacity: 0; }
|
||||
.ps { color: var(--green); font-weight: 700; } .cmd { color: #fff; font-weight: 700; }
|
||||
.caret { display: inline-block; width: .55em; height: 1.05em; background: #e5e7eb; vertical-align: -0.18em; margin-left: 3px; }
|
||||
.caret { display: inline-block; width: .5em; height: 1.05em; background: #e5e7eb; vertical-align: -0.18em; margin-left: 4px; }
|
||||
.tag { color: var(--dim); } .okk { color: var(--green); font-weight: 800; } .bad { color: var(--red); font-weight: 800; }
|
||||
.num { color: #fff; font-weight: 800; } .loc { color: var(--cyan); } .warnk { color: var(--yellow); font-weight: 800; }
|
||||
.pill { position: absolute; display: inline-flex; align-items: center; gap: 12px; border-radius: 99px; background: rgba(13,20,38,.94);
|
||||
border: 2px solid var(--line-2); box-shadow: 0 20px 50px rgba(0,0,0,.45); font: 800 40px/1 var(--display); color: #fff;
|
||||
padding: 18px 34px; letter-spacing: -0.015em; white-space: nowrap; opacity: 0; }
|
||||
.pill b { color: var(--accent); font-weight: 900; } .pill.green b { color: var(--green); }
|
||||
|
||||
/* ---------- 1 hook ---------- */
|
||||
#appcard { left: 230px; top: 330px; width: 520px; text-align: center; }
|
||||
#appcard img { width: 300px; height: 300px; border-radius: 66px; display: block; margin: 0 auto;
|
||||
box-shadow: 0 40px 90px rgba(0,0,0,.55), 0 0 0 2px rgba(255,255,255,.08); }
|
||||
#appcard .nm { margin-top: 30px; font: 800 46px/1 var(--display); letter-spacing: -0.02em; }
|
||||
#appcard .done { display: inline-flex; align-items: center; gap: 12px; margin-top: 22px; font: 800 30px/1 var(--display); color: #052e16;
|
||||
background: var(--green); border-radius: 99px; padding: 12px 26px; }
|
||||
#todo { left: 870px; top: 320px; width: 820px; }
|
||||
#todo .ch { font-family: var(--display); font-size: 30px; }
|
||||
#todo .row { display: flex; align-items: center; gap: 24px; padding: 20px 30px; border-bottom: 1px solid var(--line); font: 700 40px/1.1 var(--display);
|
||||
color: var(--ink); opacity: 0; letter-spacing: -0.01em; }
|
||||
#todo .row:last-child { border-bottom: 0; }
|
||||
#todo .box { width: 38px; height: 38px; border-radius: 10px; border: 3px solid var(--line-2); flex: none; }
|
||||
#mail { left: 230px; width: 1460px; top: 806px; height: 156px; background: #fff; color: #16181d; border-radius: 22px; opacity: 0;
|
||||
display: grid; grid-template-columns: 50px 1fr auto; column-gap: 8px; padding: 24px 40px 24px 26px; box-shadow: 0 30px 80px rgba(0,0,0,.55); }
|
||||
#mail .dot { width: 18px; height: 18px; border-radius: 50%; background: #2563eb; margin: 12px 0 0 14px; }
|
||||
#mail .from { font: 800 32px/1.2 var(--text); color: #101216; }
|
||||
#mail .date { font: 500 26px/1.3 var(--text); color: #6b7280; text-align: right; }
|
||||
#mail .subj { grid-column: 2 / 4; font: 700 30px/1.35 var(--text); color: #1d5fe0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#mail .prev { grid-column: 2 / 4; font: 400 26px/1.35 var(--text); color: #5d6575; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#mail .prev b { color: #b91c1c; font-weight: 800; }
|
||||
/* ---------- 1 game: the game's own screens ---------- */
|
||||
.phone { position: absolute; top: 290px; width: 330px; height: 676px; border-radius: 46px; overflow: hidden; opacity: 0;
|
||||
background: #000 center / cover no-repeat; box-shadow: 0 40px 90px rgba(0,0,0,.6), 0 0 0 6px #0b0e16, 0 0 0 8px rgba(255,255,255,.12); }
|
||||
|
||||
/* ---------- 2 init ---------- */
|
||||
#folder { left: 150px; top: 300px; width: 540px; height: 680px; }
|
||||
#folder .ch { font-family: var(--display); }
|
||||
#folder .empty { position: absolute; left: 0; right: 0; top: 300px; text-align: center; font: 600 30px/1 var(--text); color: var(--dim); }
|
||||
#folder .files { padding: 18px 0; }
|
||||
#folder .f { display: flex; align-items: center; gap: 16px; padding: 9px 30px; font: 600 27px/1.2 var(--mono); color: var(--ink); opacity: 0; }
|
||||
#folder .f .ic { width: 26px; height: 32px; border-radius: 5px; background: var(--surface-2); border: 2px solid var(--line-2); flex: none; }
|
||||
#folder .f .d { color: var(--dim); }
|
||||
#initterm { left: 750px; top: 300px; width: 1030px; height: 680px; }
|
||||
#initterm .body { padding: 22px 30px; font-size: 27px; line-height: 1.55; }
|
||||
/* ---------- 2 pile ---------- */
|
||||
.pcard { opacity: 0; transform-origin: 50% 50%; }
|
||||
.pcard .body { position: relative; height: 330px; overflow: hidden; }
|
||||
.pcard .rows { position: absolute; left: 0; right: 0; top: 0; padding: 14px 26px; font: 500 21px/1.6 var(--mono); color: var(--ink-2); }
|
||||
.pcard .rows div { white-space: pre; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pcard .rows .okk { font-weight: 800; }
|
||||
.pcard .shade { position: absolute; inset: 0; background: #04060c; opacity: 0; pointer-events: none; }
|
||||
.pcard .grid { position: absolute; inset: 16px 22px; }
|
||||
.pcard .tile { position: absolute; border-radius: 6px; background: center / cover no-repeat; box-shadow: 0 6px 16px rgba(0,0,0,.5), 0 0 0 1px rgba(255,255,255,.08); }
|
||||
|
||||
/* ---------- 3 plan ---------- */
|
||||
#prompt { left: 210px; top: 270px; width: 1500px; padding: 24px 32px; display: flex; align-items: center; gap: 22px;
|
||||
background: var(--surface-2); border: 2px solid var(--accent-2); border-radius: 22px; box-shadow: 0 20px 60px rgba(0,0,0,.4), 0 0 0 6px rgba(91,92,240,.12); opacity: 0; }
|
||||
#prompt .who { font: 800 22px/1 var(--text); color: var(--accent-ink); text-transform: uppercase; letter-spacing: .08em; flex: none; }
|
||||
#prompt .txt { font: 700 40px/1.2 var(--display); color: #fff; letter-spacing: -0.01em; }
|
||||
#planterm { left: 210px; top: 392px; width: 1500px; opacity: 0; }
|
||||
#planterm .body { padding: 16px 30px 20px; font-size: 24px; line-height: 1.5; }
|
||||
#plan-pill { left: 50%; top: 940px; }
|
||||
/* ---------- 3 reject: the finding ---------- */
|
||||
#finding { left: 50%; top: 52%; width: max-content; padding: 34px 44px 38px; border-radius: 26px; background: #1a0d12;
|
||||
border: 3px solid rgba(255,90,95,.65); box-shadow: 0 0 0 10px rgba(229,72,77,.12), 0 0 160px rgba(229,72,77,.35), 0 50px 120px rgba(0,0,0,.7);
|
||||
font-family: var(--mono); opacity: 0; transform-origin: 50% 50%; }
|
||||
#finding .l1 { display: flex; align-items: center; gap: 18px; white-space: nowrap; font-size: 31px; font-weight: 700; color: #ffd6d7; }
|
||||
#finding .sev { color: #fff; background: var(--red-2); border-radius: 9px; padding: 3px 14px; font-weight: 800; }
|
||||
#finding .gl { color: #fff; font-weight: 800; }
|
||||
#finding .l2 { margin-top: 18px; white-space: nowrap; font-size: 26px; color: var(--ink-2); }
|
||||
#finding .l2 .loc { font-weight: 700; }
|
||||
|
||||
/* ---------- 4 store ---------- */
|
||||
#copy { left: 110px; top: 300px; width: 640px; }
|
||||
#copy .row { display: grid; grid-template-columns: 76px 1fr; align-items: center; gap: 18px; padding: 22px 28px; border-bottom: 1px solid var(--line); opacity: 0; }
|
||||
#copy .row:last-child { border-bottom: 0; }
|
||||
#copy .lg { font: 800 26px/1 var(--mono); color: var(--accent-ink); background: rgba(91,92,240,.18); border-radius: 10px; padding: 10px 0; text-align: center; }
|
||||
#copy .tt { font: 800 33px/1.2 var(--display); white-space: nowrap; color: #fff; letter-spacing: -0.01em; }
|
||||
#copy .tt.rtl { direction: rtl; text-align: right; }
|
||||
#shots { left: 800px; top: 300px; width: 1020px; height: 700px; }
|
||||
#shots .lab { position: absolute; font: 700 17px/1.15 var(--text); color: var(--muted); text-align: center; white-space: pre; opacity: 0; }
|
||||
#shots .lang { position: absolute; font: 800 20px/1 var(--mono); color: var(--accent-ink); opacity: 0; }
|
||||
/* ---------- 4 ask: the request ---------- */
|
||||
#prompt { left: 260px; top: 470px; width: 1400px; padding: 30px 38px; display: flex; flex-direction: column; gap: 16px;
|
||||
background: rgba(21,31,54,.92); border: 2px solid var(--accent-2); border-radius: 26px; box-shadow: 0 30px 90px rgba(0,0,0,.6), 0 0 0 8px rgba(91,92,240,.12); opacity: 0; }
|
||||
#prompt .who { display: flex; align-items: center; gap: 12px; font: 800 22px/1 var(--text); color: var(--accent-ink); text-transform: uppercase; letter-spacing: .1em; }
|
||||
#prompt .txt { font: 700 50px/1.2 var(--display); color: #fff; letter-spacing: -0.015em; white-space: nowrap; }
|
||||
#prompt .sent { position: absolute; right: 30px; bottom: 30px; width: 58px; height: 58px; border-radius: 50%; background: var(--accent-2);
|
||||
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 28px; opacity: .35; }
|
||||
|
||||
/* ---------- 5 montage ---------- */
|
||||
.mterm { left: 120px; top: 110px; width: 1680px; }
|
||||
.mterm .body { padding: 22px 34px 26px; font-size: 30px; line-height: 1.6; }
|
||||
#shots { left: 110px; top: 90px; width: 1700px; height: 760px; }
|
||||
#shots .lab { position: absolute; font: 700 20px/1.15 var(--text); color: var(--muted); text-align: center; white-space: nowrap; opacity: 0; }
|
||||
#shots .lang { position: absolute; font: 800 22px/1 var(--mono); color: var(--accent-ink); opacity: 0; }
|
||||
#shots .tile { position: absolute; border-radius: 8px; background-size: cover; background-position: center; opacity: 0;
|
||||
box-shadow: 0 10px 26px rgba(0,0,0,.5), 0 0 0 1px rgba(255,255,255,.08); }
|
||||
#store-pill { left: 430px; top: 770px; }
|
||||
#shotcmd { left: 430px; top: 880px; transform: translateX(-50%); font: 700 26px/1 var(--mono); color: var(--ink-2); opacity: 0; white-space: nowrap; }
|
||||
|
||||
/* ---------- 5 products ---------- */
|
||||
#prodterm { left: 160px; top: 300px; width: 1600px; }
|
||||
#prodterm .body { padding: 20px 32px; font-size: 26px; line-height: 1.62; }
|
||||
#prod-pill { left: 50%; top: 900px; }
|
||||
|
||||
/* ---------- 6 forms ---------- */
|
||||
#prop { left: 110px; top: 300px; width: 1010px; }
|
||||
#prop { left: 110px; top: 110px; width: 1010px; }
|
||||
#prop .ln { display: block; font: 600 25px/1.45 var(--mono); padding: 8px 28px; opacity: 0; white-space: nowrap; position: relative; }
|
||||
#prop .ln .v1 { display: block; white-space: pre; } #prop .ln .v1 .loc { float: right; }
|
||||
#prop .ln .v2 { display: block; padding-left: 11ch; color: var(--muted); }
|
||||
#prop .ln .k { color: var(--green); font-weight: 800; } #prop .ln .w { color: #fff; font-weight: 800; }
|
||||
#prop .ln.ask { display: block; white-space: normal; margin: 10px 16px 14px; border-radius: 14px; padding: 14px 18px; }
|
||||
#prop .ln.ask { display: block; white-space: normal; margin: 10px 16px 18px; border-radius: 14px; padding: 14px 18px; }
|
||||
#prop .ln.ask .k { color: var(--yellow); }
|
||||
#prop .ln.ask .glow { position: absolute; inset: 0; border-radius: 14px; background: var(--yellow-bg); border: 3px solid var(--yellow); opacity: 0; }
|
||||
#prop .ln.ask > span:not(.glow) { position: relative; }
|
||||
#prop .askn { display: block; padding: 0 28px 22px; font: 700 26px/1.3 var(--display); color: var(--yellow); opacity: 0; }
|
||||
#form { left: 1170px; top: 300px; width: 640px; }
|
||||
#form .ch { font-family: var(--display); }
|
||||
#form { left: 1170px; top: 110px; width: 640px; }
|
||||
#form .row { display: flex; align-items: center; gap: 20px; padding: 0 28px; height: 64px; border-bottom: 1px solid var(--line); font: 700 30px/1 var(--text); color: var(--ink-2); }
|
||||
#form .row:last-child { border-bottom: 0; }
|
||||
#form .box { width: 38px; height: 38px; border-radius: 10px; border: 3px solid var(--line-2); position: relative; flex: none; }
|
||||
#form .box .pi { position: absolute; left: 4px; top: 4px; width: 24px; height: 24px; color: #04150b; }
|
||||
#form .box .fill { position: absolute; inset: -3px; border-radius: 10px; background: var(--green); opacity: 0; }
|
||||
#form .row .tg { margin-left: auto; font: 700 21px/1 var(--mono); color: var(--green); opacity: 0; }
|
||||
#forms-pill { left: 1490px; top: 900px; }
|
||||
|
||||
/* ---------- 7 consoles ---------- */
|
||||
#cpterm { left: 160px; top: 290px; width: 1600px; }
|
||||
#cpterm .body { padding: 18px 30px 22px; font-size: 24px; line-height: 1.5; }
|
||||
#form .count { padding: 18px 28px 22px; font: 800 44px/1 var(--display); color: #fff; border-top: 2px solid var(--line); opacity: 0; }
|
||||
#form .count b { color: var(--green); }
|
||||
#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; }
|
||||
#cpterm .body { font-size: 26px; line-height: 1.55; }
|
||||
.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; }
|
||||
.fd .tint { position: absolute; inset: 0; border-radius: 16px; }
|
||||
/* ---------- 6 scan ---------- */
|
||||
#scanterm { left: 120px; top: 290px; width: 1680px; }
|
||||
#scanterm .body { padding: 20px 32px 26px; font-size: 27px; line-height: 1.5; }
|
||||
.fd { position: relative; display: flex; align-items: center; gap: 16px; margin: 8px 0; padding: 10px 18px; border-radius: 14px; white-space: nowrap; opacity: 0; }
|
||||
.fd .tint { position: absolute; inset: 0; border-radius: 14px; }
|
||||
.fd .tint.b { background: var(--red-bg); border: 2px solid rgba(255,90,95,.4); }
|
||||
.fd .tint.g { background: var(--green-bg); border: 2px solid rgba(61,220,132,.55); opacity: 0; }
|
||||
.fd > *:not(.tint) { position: relative; }
|
||||
.fd .l1 { display: flex; align-items: center; gap: 14px; white-space: nowrap; overflow: hidden; }
|
||||
.fd .sev { display: inline-block; font-weight: 800; color: #fff; background: var(--red-2); border-radius: 8px; padding: 1px 12px; }
|
||||
.fd .sev.high { background: #c2410c; }
|
||||
.fd .gl { color: #fff; font-weight: 800; } .fd .ttl { color: #ffd6d7; font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
|
||||
.fd .why { display: flex; align-items: center; gap: 12px; margin-top: 8px; font: 700 31px/1.2 var(--display); color: #fff; letter-spacing: -0.005em; }
|
||||
.fd .why .pi { color: var(--red); font-size: 30px; }
|
||||
.fd .why .at { margin-left: auto; font: 600 24px/1 var(--mono); }
|
||||
.fd .why .fixed { margin-left: 18px; }
|
||||
.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; }
|
||||
.fd .pi { font-size: 30px; }
|
||||
.fd .tx { overflow: hidden; text-overflow: ellipsis; color: #ffd6d7; font-weight: 600; }
|
||||
.fd .sev { font-weight: 800; color: #fff; }
|
||||
|
||||
/* ---------- 7, 9-13 desktop window ---------- */
|
||||
/* ---------- 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;
|
||||
|
|
@ -192,92 +155,71 @@ 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 (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;
|
||||
/* the pointer (Phosphor navigation-arrow, fill) */
|
||||
.dcur { position: absolute; z-index: 6; width: 56px; height: 56px; 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; }
|
||||
.callout { position: absolute; z-index: 5; font: 800 32px/1 var(--display); color: #fff; background: var(--accent-2);
|
||||
padding: 16px 26px; border-radius: 16px; white-space: nowrap; box-shadow: 0 20px 50px rgba(0,0,0,.55), 0 0 0 3px rgba(255,255,255,.12);
|
||||
transform-origin: 50% 100%; opacity: 0; }
|
||||
.callout.green { background: #15803d; }
|
||||
.ring { position: absolute; z-index: 4; border: 4px solid var(--accent); border-radius: 14px; opacity: 0;
|
||||
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); }
|
||||
|
||||
/* ---------- 13 submit: the end card ---------- */
|
||||
#brand { left: 0; right: 0; top: 330px; text-align: center; }
|
||||
/* ---------- 9 end card ---------- */
|
||||
#brand { left: 0; right: 0; top: 250px; 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)); }
|
||||
#brand .word { margin-top: 30px; font: 900 118px/1 var(--display); letter-spacing: -0.05em; color: #fff; }
|
||||
#brand .url { margin-top: 26px; font: 800 56px/1 var(--display); color: var(--accent); letter-spacing: -0.02em; }
|
||||
#brand .plat { margin-top: 26px; font: 600 34px/1.3 var(--text); color: var(--ink-2); }
|
||||
#brand .mark { width: 200px; height: 200px; display: block; margin: 0 auto; filter: drop-shadow(0 30px 70px rgba(91,92,240,.6)); }
|
||||
#brand .word { margin-top: 26px; font: 900 120px/1 var(--display); letter-spacing: -0.05em; color: #fff; }
|
||||
#brand .line { margin-top: 40px; font: 750 64px/1.1 var(--display); letter-spacing: -0.025em; color: #fff; }
|
||||
#brand .url { margin-top: 28px; font: 700 42px/1 var(--display); color: var(--accent); letter-spacing: -0.01em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="bg"><div class="grid"></div><div class="glow g1"></div><div class="glow g2"></div></div>
|
||||
<div id="veil"></div>
|
||||
|
||||
<div class="scene" id="s-hook"><div class="cam">
|
||||
<div id="appcard" class="abs"><img id="appicon" src="examples/demo-game/store/icon/icon.png" alt=""><div class="nm"></div><span class="done"><span class="pi i-check"></span>Done</span></div>
|
||||
<div class="card" id="todo"><div class="ch">Still to do on the stores</div><div class="rows"></div></div>
|
||||
<div id="mail" class="abs"><span class="dot"></span><span class="from"></span><span class="date"></span><span class="subj"></span><span class="prev"></span></div>
|
||||
<div class="scene" id="s-game"><div class="cam" id="game"></div></div>
|
||||
<div class="scene" id="s-pile"><div class="cam" id="pile"></div></div>
|
||||
<div class="scene" id="s-reject"><div class="cam">
|
||||
<div id="finding" class="abs"><div class="l1"></div><div class="l2"></div></div>
|
||||
</div></div>
|
||||
<div class="scene" id="s-ask"><div class="cam">
|
||||
<div id="prompt" class="abs"><span class="who"><span class="pi i-term"></span>You, to your AI agent</span><span class="txt"></span><span class="sent"><span class="pi i-send"></span></span></div>
|
||||
</div></div>
|
||||
|
||||
<div class="scene" id="s-init"><div class="cam">
|
||||
<div class="card" id="folder"><div class="ch"><span class="dots"><i></i><i></i><i></i></span><span class="fname"></span></div><div class="empty">empty folder</div><div class="files"></div></div>
|
||||
<div class="card term" id="initterm"><div class="ch"><span class="dots"><i></i><i></i><i></i></span>Terminal</div><div class="body"></div></div>
|
||||
<div class="scene" id="s-builds"><div class="cam">
|
||||
<div class="card term mterm" id="buildterm"><div class="ch"><span class="dots"><i></i><i></i><i></i></span>Terminal</div><div class="body"></div></div>
|
||||
</div></div>
|
||||
|
||||
<div class="scene" id="s-plan"><div class="cam">
|
||||
<div id="prompt" class="abs"><span class="who">You, to your agent</span><span class="txt"></span></div>
|
||||
<div class="card term" id="planterm"><div class="ch"><span class="dots"><i></i><i></i><i></i></span>Terminal<span class="sub">read-only</span></div><div class="body"></div></div>
|
||||
<div class="pill" id="plan-pill"></div>
|
||||
</div></div>
|
||||
|
||||
<div class="scene" id="s-store"><div class="cam">
|
||||
<div class="card" id="copy"><div class="ch"><span class="dots"><i></i><i></i><i></i></span><span class="fname"></span></div><div class="rows"></div></div>
|
||||
<div id="shots" class="abs"></div>
|
||||
<div class="pill" id="store-pill"></div>
|
||||
<div id="shotcmd" class="abs"><span class="ps">$</span> <span class="cmd"></span></div>
|
||||
</div></div>
|
||||
|
||||
<div class="scene" id="s-shots"><div class="cam"><div id="shots" class="abs"></div></div></div>
|
||||
<div class="scene" id="s-products"><div class="cam">
|
||||
<div class="card term" id="prodterm"><div class="ch"><span class="dots"><i></i><i></i><i></i></span>Terminal<span class="sub">App Store Connect · Google Play</span></div><div class="body"></div></div>
|
||||
<div class="pill green" id="prod-pill"></div>
|
||||
<div class="card term mterm" id="prodterm"><div class="ch"><span class="dots"><i></i><i></i><i></i></span>Terminal<span class="sub">App Store Connect · Google Play</span></div><div class="body"></div></div>
|
||||
</div></div>
|
||||
|
||||
<div class="scene" id="s-forms"><div class="cam">
|
||||
<div class="card" id="prop"><div class="ch"><span class="dots"><i></i><i></i><i></i></span>storebound scan<span class="sub">declarations proposal</span></div><div class="body"></div></div>
|
||||
<div class="card" id="form"><div class="ch">Data safety<span class="sub">Google Play</span></div><div class="body"></div></div>
|
||||
<div class="pill green" id="forms-pill"></div>
|
||||
<div class="card" id="form"><div class="ch">Data safety<span class="sub">Google Play</span></div><div class="body"></div><div class="count"></div></div>
|
||||
</div></div>
|
||||
|
||||
<div class="scene" id="s-consoles"><div class="cam">
|
||||
<div class="card term" id="cpterm"><div class="ch"><span class="dots"><i></i><i></i><i></i></span>Terminal<span class="sub">offline · no store access</span></div><div class="body"></div></div>
|
||||
<div class="pill" id="cp-pill"></div>
|
||||
<div class="card term mterm" id="cpterm"><div class="ch"><span class="dots"><i></i><i></i><i></i></span>Terminal<span class="sub">offline · no store access</span></div><div class="body"></div></div>
|
||||
<div class="dhost"></div>
|
||||
</div></div>
|
||||
|
||||
<div class="scene" id="s-rejections"><div class="cam">
|
||||
<div class="scene" id="s-scan"><div class="cam">
|
||||
<div class="card term" id="scanterm"><div class="ch"><span class="dots"><i></i><i></i><i></i></span>Terminal</div><div class="body"></div></div>
|
||||
</div></div>
|
||||
|
||||
<div class="scene" id="s-portfolio"><div class="cam"></div></div>
|
||||
<div class="scene" id="s-charts"><div class="cam"></div></div>
|
||||
<div class="scene" id="s-catalog"><div class="cam"></div></div>
|
||||
<div class="scene" id="s-resubmit"><div class="cam"></div></div>
|
||||
|
||||
<div class="scene" id="s-submit"><div class="cam">
|
||||
<div class="scene" id="s-button"><div class="cam"></div></div>
|
||||
<div class="scene" id="s-waiting"><div class="cam"></div></div>
|
||||
<div class="scene" id="s-end"><div class="cam">
|
||||
<div id="brand" class="abs">
|
||||
<img class="mark" src="brand/logo-mark-256.png" alt="">
|
||||
<div class="word">storebound</div>
|
||||
<div class="line"></div>
|
||||
<div class="url"></div>
|
||||
<div class="plat"></div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div id="capband"></div>
|
||||
<div id="cap"></div>
|
||||
<div id="edgeband"></div>
|
||||
<div id="edge"></div>
|
||||
<div id="vig"></div>
|
||||
</div>
|
||||
<script type="module" src="teaser.js"></script>
|
||||
|
|
|
|||
|
|
@ -1,150 +1,288 @@
|
|||
// Original teaser music and sound design (v4, calm), synthesized from nothing with the Web Audio API
|
||||
// (OfflineAudioContext): no samples, no downloaded audio. D major, one chord per scene: warm pads that cross-fade on the
|
||||
// scene changes, a slow, soft plucked arpeggio, a quiet sub, and only soft accents on the scene changes (a bell and a
|
||||
// short airy swell into each one). On top, quiet UI sounds from script.js: key clicks for typed text, ticks for lines
|
||||
// and rows, a low tone per finding, rising chimes for the fixes, a warm swell under the end card. Deterministic.
|
||||
// renderWav() returns a 32-bit float stereo WAV (48 kHz) as base64; render.ts masters it with ffmpeg loudnorm.
|
||||
import { CUTS, DURATION, keyTimes, SCENES, scene, SUBMIT, ticks } from './script.js';
|
||||
// Original teaser music and sound design (v8, a story arc), synthesized from nothing with the Web Audio API
|
||||
// (OfflineAudioContext): no samples, no downloaded audio. 120 BPM (a beat every half second; every cut lands on one).
|
||||
// calm game a warm D major pad, a slow soft pluck
|
||||
// tension pile B minor, a low pulse in eighths that grows, a ticking clock, a riser; a thud per card
|
||||
// dark reject a low boom, a dark drone that sinks to almost nothing
|
||||
// silence ask room tone, the key clicks of the request, one soft note when it is sent; a swell into the drop
|
||||
// drive montage+scan kick, bass, arpeggio, hats, claps: one layer more per montage beat (I-vi-IV-V, a chord per bar);
|
||||
// a chime per fix, rising
|
||||
// stop button the music stops on the cut; only a quiet low pulse, like holding your breath
|
||||
// resolve waiting+end the click, then a wide D major add9 swell, bells, a slow arpeggio under the end card
|
||||
// Deterministic. renderWav() returns a 32-bit float stereo WAV (48 kHz) as base64; render.ts masters it with ffmpeg.
|
||||
import { CLICK_T, CUTS, DURATION, keyTimes, MONTAGE, scene, ticks } from './script.js';
|
||||
|
||||
const SR = 48000;
|
||||
/** One beat (s): 120 BPM. Every scene starts on a whole number of beats (tests/teaser.test.ts). */
|
||||
export const STEP = 0.5;
|
||||
const BAR = STEP * 4;
|
||||
const midi = m => 440 * Math.pow(2, (m - 69) / 12);
|
||||
function rng(seed) { // mulberry32
|
||||
let a = seed >>> 0;
|
||||
return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
||||
}
|
||||
|
||||
// chords (root midi in octave 3, intervals): one per scene, in scene order
|
||||
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', consoles: 'A', rejections: 'Em', portfolio: 'G', charts: 'D', catalog: 'Bm', resubmit: 'G', submit: 'D' };
|
||||
const ARP = [0, 1, 2, 3, 2, 1, 2, 3];
|
||||
/** 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;
|
||||
/** The arc: section boundaries in seconds, from the scenes. */
|
||||
export function sections() {
|
||||
const s = id => scene(id).start, e = id => scene(id).end;
|
||||
return { calm: [0, s('pile')], tension: [s('pile'), s('reject')], dark: [s('reject'), s('ask')], silence: [s('ask'), s('builds')],
|
||||
drive: [s('builds'), s('button')], stop: [s('button'), CLICK_T], resolve: [CLICK_T, e('end')] };
|
||||
}
|
||||
// chords (root midi, intervals)
|
||||
const CH = { D: [50, [0, 4, 7, 14]], Bm: [47, [0, 3, 7, 14]], G: [43, [0, 4, 7, 14]], A: [45, [0, 4, 7, 14]], Dmaj9: [50, [0, 4, 7, 11, 14]] };
|
||||
const PROG = ['D', 'Bm', 'G', 'A'];
|
||||
|
||||
export async function renderMusic() {
|
||||
const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(DURATION * SR) + SR * 2, sampleRate: SR });
|
||||
const R = rng(20261012);
|
||||
const R = rng(20261013);
|
||||
const S = sections();
|
||||
|
||||
const noise = ctx.createBuffer(2, SR * 3, SR);
|
||||
const noise = ctx.createBuffer(2, SR * 6, SR);
|
||||
for (let c = 0; c < 2; c++) { const d = noise.getChannelData(c); for (let i = 0; i < d.length; i++) d[i] = R() * 2 - 1; }
|
||||
const ir = ctx.createBuffer(2, Math.floor(SR * 3.2), SR);
|
||||
for (let c = 0; c < 2; c++) { const d = ir.getChannelData(c); for (let i = 0; i < d.length; i++) d[i] = (R() * 2 - 1) * Math.pow(1 - i / d.length, 3.6); }
|
||||
const ir = ctx.createBuffer(2, Math.floor(SR * 3.4), SR);
|
||||
for (let c = 0; c < 2; c++) { const d = ir.getChannelData(c); for (let i = 0; i < d.length; i++) d[i] = (R() * 2 - 1) * Math.pow(1 - i / d.length, 3.4); }
|
||||
|
||||
// buses: a gentle compressor, a long room
|
||||
const comp = ctx.createDynamicsCompressor();
|
||||
comp.threshold.value = -18; comp.knee.value = 10; comp.ratio.value = 2.5; comp.attack.value = 0.02; comp.release.value = 0.4;
|
||||
comp.threshold.value = -16; comp.knee.value = 8; comp.ratio.value = 3; comp.attack.value = 0.008; comp.release.value = 0.25;
|
||||
const master = ctx.createGain(); master.gain.value = 0.7;
|
||||
master.connect(comp); comp.connect(ctx.destination);
|
||||
const verb = ctx.createConvolver(); verb.buffer = ir;
|
||||
const verbIn = ctx.createGain(); verbIn.gain.value = 0.55; verbIn.connect(verb); verb.connect(master);
|
||||
const sfx = ctx.createGain(); sfx.gain.value = 0.7; sfx.connect(master);
|
||||
const verbIn = ctx.createGain(); verbIn.gain.value = 0.5; verbIn.connect(verb); verb.connect(master);
|
||||
const sfx = ctx.createGain(); sfx.gain.value = 0.75; sfx.connect(master);
|
||||
const sfxVerb = ctx.createGain(); sfxVerb.gain.value = 0.3; sfxVerb.connect(verbIn);
|
||||
// the drums and bass: their own bus, so the stop can cut them all at once
|
||||
const drive = ctx.createGain(); drive.connect(master);
|
||||
drive.gain.setValueAtTime(1, 0); drive.gain.setValueAtTime(1, S.stop[0] - 0.01); drive.gain.linearRampToValueAtTime(0, S.stop[0] + 0.03);
|
||||
|
||||
const env = (g, t, a, peak, d, end = 0.0003) => {
|
||||
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + a); g.gain.exponentialRampToValueAtTime(end, t + a + d);
|
||||
};
|
||||
const noiseSrc = (t, dur, off = R() * 1.5) => { const s = ctx.createBufferSource(); s.buffer = noise; s.start(t, off, dur); return s; };
|
||||
|
||||
/** A warm pad: detuned saws through a soft low-pass, slow attack and release (cross-fades with the next one). */
|
||||
function pad(t0, t1, notes, g = 0.035, cutoff = 1100) {
|
||||
/** A warm pad: detuned saws through a soft low-pass, slow attack and release. */
|
||||
function pad(t0, t1, notes, g = 0.035, cutoff = 1100, attack = 1.2, release = 1.0, to = master, cutoffTo = null) {
|
||||
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cutoff; lp.Q.value = 0.5;
|
||||
if (cutoffTo) { lp.frequency.setValueAtTime(cutoff, t0); lp.frequency.exponentialRampToValueAtTime(cutoffTo, t1); }
|
||||
const v = ctx.createGain();
|
||||
v.gain.setValueAtTime(0, t0); v.gain.linearRampToValueAtTime(g, t0 + 1.2); v.gain.setValueAtTime(g, t1 - 0.2); v.gain.linearRampToValueAtTime(0, t1 + 1.0);
|
||||
lp.connect(v); v.connect(master);
|
||||
v.gain.setValueAtTime(0, t0); v.gain.linearRampToValueAtTime(g, t0 + attack); v.gain.setValueAtTime(g, Math.max(t0 + attack, t1 - 0.05)); v.gain.linearRampToValueAtTime(0, t1 + release);
|
||||
lp.connect(v); v.connect(to);
|
||||
const s = ctx.createGain(); s.gain.value = 0.7; v.connect(s); s.connect(verbIn);
|
||||
for (const m of notes) for (const det of [-7, 7]) {
|
||||
const o = ctx.createOscillator(); o.type = 'sawtooth'; o.frequency.value = midi(m); o.detune.value = det;
|
||||
const p = ctx.createStereoPanner(); p.pan.value = det < 0 ? -0.35 : 0.35;
|
||||
o.connect(p); p.connect(lp); o.start(t0); o.stop(t1 + 1.1);
|
||||
o.connect(p); p.connect(lp); o.start(t0); o.stop(t1 + release + 0.1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
/** A soft pluck (sine + a little triangle), the arpeggio voice. */
|
||||
function pluck(t, m, g = 0.05, pan = 0) {
|
||||
const v = ctx.createGain(); env(v, t, 0.006, g, 1.1);
|
||||
/** A soft pluck (sine + a little triangle). */
|
||||
function pluck(t, m, g = 0.05, pan = 0, len = 1.1, to = master) {
|
||||
const v = ctx.createGain(); env(v, t, 0.006, g, len);
|
||||
const p = ctx.createStereoPanner(); p.pan.value = pan;
|
||||
v.connect(p); p.connect(master);
|
||||
const s = ctx.createGain(); s.gain.value = 0.6; p.connect(s); s.connect(verbIn);
|
||||
v.connect(p); p.connect(to);
|
||||
const s = ctx.createGain(); s.gain.value = 0.5; p.connect(s); s.connect(verbIn);
|
||||
for (const [type, k, mul] of [['sine', 1, 1], ['triangle', 2, 0.18]]) {
|
||||
const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m) * k;
|
||||
const og = ctx.createGain(); og.gain.value = mul; o.connect(og); og.connect(v); o.start(t); o.stop(t + 1.3);
|
||||
const og = ctx.createGain(); og.gain.value = mul; o.connect(og); og.connect(v); o.start(t); o.stop(t + len + 0.2);
|
||||
}
|
||||
}
|
||||
/** A quiet sub under each scene. */
|
||||
function sub(t0, t1, m, g = 0.07) {
|
||||
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(m - 12);
|
||||
const v = ctx.createGain(); v.gain.setValueAtTime(0, t0); v.gain.linearRampToValueAtTime(g, t0 + 0.8); v.gain.setValueAtTime(g, t1 - 0.3); v.gain.linearRampToValueAtTime(0, t1 + 0.4);
|
||||
o.connect(v); v.connect(master); o.start(t0); o.stop(t1 + 0.5);
|
||||
}
|
||||
/** The soft accent on a scene change: a bell an octave or two up, with a long tail. */
|
||||
function bell(t, m, g = 0.05, pan = 0) {
|
||||
const p = ctx.createStereoPanner(); p.pan.value = pan; p.connect(sfx); p.connect(sfxVerb);
|
||||
for (const [k, mul, d] of [[1, 1, 2.2], [2.01, 0.25, 1.2], [3.0, 0.08, 0.7]]) {
|
||||
for (const [k, mul, d] of [[1, 1, 2.4], [2.01, 0.25, 1.3], [3.0, 0.08, 0.7]]) {
|
||||
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(m) * k;
|
||||
const v = ctx.createGain(); env(v, t, 0.004, g * mul, d); o.connect(v); v.connect(p); o.start(t); o.stop(t + d + 0.1);
|
||||
}
|
||||
}
|
||||
/** A short airy swell into a scene change (very quiet). */
|
||||
function swell(t, dur = 0.9, g = 0.03) {
|
||||
const n = noiseSrc(t - dur, dur + 0.1); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 0.8;
|
||||
bp.frequency.setValueAtTime(600, t - dur); bp.frequency.exponentialRampToValueAtTime(3200, t);
|
||||
const v = ctx.createGain(); v.gain.setValueAtTime(0.0001, t - dur); v.gain.exponentialRampToValueAtTime(g, t - 0.05); v.gain.linearRampToValueAtTime(0, t + 0.1);
|
||||
n.connect(bp); bp.connect(v); v.connect(sfx); v.connect(sfxVerb);
|
||||
}
|
||||
function tone(t, m, g, len = 0.25, type = 'sine', pan = 0) {
|
||||
const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m);
|
||||
const p = ctx.createStereoPanner(); p.pan.value = pan;
|
||||
const v = ctx.createGain(); env(v, t, 0.004, g, len); o.connect(v); v.connect(p); p.connect(sfx); p.connect(sfxVerb);
|
||||
o.start(t); o.stop(t + len + 0.05);
|
||||
}
|
||||
function tick(t, g = 0.03, f0 = 3000) {
|
||||
const n = noiseSrc(t, 0.015); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = f0; bp.Q.value = 3;
|
||||
const v = ctx.createGain(); env(v, t, 0.0006, g, 0.014); n.connect(bp); bp.connect(v); v.connect(sfx);
|
||||
/** Filtered noise: a swell / riser into time t (dur long), band-pass sweeping up. */
|
||||
function riser(t, dur, g = 0.04, f0 = 400, f1 = 5000, to = sfx) {
|
||||
const n = noiseSrc(t - dur, Math.min(dur + 0.1, 4.4)); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 0.9;
|
||||
bp.frequency.setValueAtTime(f0, t - dur); bp.frequency.exponentialRampToValueAtTime(f1, t);
|
||||
const v = ctx.createGain(); v.gain.setValueAtTime(0.0001, t - dur); v.gain.exponentialRampToValueAtTime(g, t - 0.03); v.gain.linearRampToValueAtTime(0, t + 0.08);
|
||||
n.connect(bp); bp.connect(v); v.connect(to); v.connect(sfxVerb);
|
||||
}
|
||||
function tick(t, g = 0.03, f0 = 3000, to = sfx) {
|
||||
const n = noiseSrc(t, 0.02); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = f0; bp.Q.value = 3;
|
||||
const v = ctx.createGain(); env(v, t, 0.0006, g, 0.016); n.connect(bp); bp.connect(v); v.connect(to);
|
||||
}
|
||||
function key(t) {
|
||||
const n = noiseSrc(t, 0.02); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2400 + R() * 1800; bp.Q.value = 2.5;
|
||||
const p = ctx.createStereoPanner(); p.pan.value = R() * 0.4 - 0.2;
|
||||
const v = ctx.createGain(); env(v, t, 0.0008, 0.05 * (0.7 + R() * 0.6), 0.016); n.connect(bp); bp.connect(v); v.connect(p); p.connect(sfx);
|
||||
const v = ctx.createGain(); env(v, t, 0.0008, 0.06 * (0.7 + R() * 0.6), 0.018); n.connect(bp); bp.connect(v); v.connect(p); p.connect(sfx);
|
||||
}
|
||||
|
||||
// ---- the bed: one chord per scene ----
|
||||
SCENES.forEach((s, i) => {
|
||||
const [root, iv] = CH[SCENE_CHORD[s.id]];
|
||||
const last = i === SCENES.length - 1;
|
||||
const t0 = Math.max(0, s.start - 0.4), t1 = last ? DURATION + 0.6 : s.end;
|
||||
const notes = [root, root + iv[1], root + iv[2], root + 12];
|
||||
pad(t0, last ? scene('submit').start + SUBMIT.logoAt : t1, notes, s.id === 'hook' ? 0.03 : 0.034, s.id === 'hook' ? 800 : 1100);
|
||||
sub(t0, t1, root, s.id === 'hook' ? 0.05 : 0.065);
|
||||
// the arpeggio: quiet under the hook, then a steady, soft pulse
|
||||
const tones = iv.map(x => root + 12 + x).concat(iv.map(x => root + 24 + x));
|
||||
for (let t = Math.max(0.5, s.start), k = 0; t < (last ? s.start + SUBMIT.logoAt : s.end) - 0.05; t += STEP, k++) {
|
||||
const m = tones[ARP[k % ARP.length] + (k % 16 >= 8 ? 1 : 0)] ?? tones[0];
|
||||
pluck(t, m, s.id === 'hook' ? 0.028 : 0.04, (k % 2 ? 0.25 : -0.25));
|
||||
/** A soft synth kick: a sine with a fast pitch drop. */
|
||||
function kick(t, g = 0.5, to = drive) {
|
||||
const o = ctx.createOscillator(); o.type = 'sine';
|
||||
o.frequency.setValueAtTime(130, t); o.frequency.exponentialRampToValueAtTime(44, t + 0.12);
|
||||
const v = ctx.createGain(); env(v, t, 0.002, g, 0.32); o.connect(v); v.connect(to); o.start(t); o.stop(t + 0.4);
|
||||
tick(t, g * 0.06, 1800, to);
|
||||
}
|
||||
/** A clap / snare: noise through a band-pass, a short tail. */
|
||||
function clap(t, g = 0.12) {
|
||||
const n = noiseSrc(t, 0.25); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1500; bp.Q.value = 0.8;
|
||||
const v = ctx.createGain(); env(v, t, 0.002, g, 0.16); n.connect(bp); bp.connect(v); v.connect(drive);
|
||||
const s = ctx.createGain(); s.gain.value = 0.4; v.connect(s); s.connect(verbIn);
|
||||
}
|
||||
function hat(t, g = 0.03, open = false) {
|
||||
const n = noiseSrc(t, open ? 0.2 : 0.05); const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 7500;
|
||||
const p = ctx.createStereoPanner(); p.pan.value = 0.2;
|
||||
const v = ctx.createGain(); env(v, t, 0.001, g, open ? 0.14 : 0.035); n.connect(hp); hp.connect(v); v.connect(p); p.connect(drive);
|
||||
}
|
||||
/** The bass: a filtered saw + sine, one note. */
|
||||
function bass(t, m, len, g = 0.11, cutoff = 520) {
|
||||
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(cutoff * 2.2, t); lp.frequency.exponentialRampToValueAtTime(cutoff, t + len * 0.8); lp.Q.value = 2;
|
||||
const v = ctx.createGain(); v.gain.setValueAtTime(0, t); v.gain.linearRampToValueAtTime(g, t + 0.008); v.gain.setValueAtTime(g * 0.8, t + len * 0.7); v.gain.linearRampToValueAtTime(0, t + len);
|
||||
lp.connect(v); v.connect(drive);
|
||||
for (const [type, mul] of [['sawtooth', 0.6], ['sine', 1]]) {
|
||||
const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m);
|
||||
const og = ctx.createGain(); og.gain.value = mul; o.connect(og); og.connect(lp); o.start(t); o.stop(t + len + 0.05);
|
||||
}
|
||||
});
|
||||
// the end card: a warm, open chord swell and one bell
|
||||
const end = scene('submit').start + SUBMIT.logoAt;
|
||||
const [er, eiv] = CH.Dadd9;
|
||||
pad(end - 0.3, DURATION + 0.6, [er - 12, er, er + eiv[1], er + eiv[2], er + eiv[3]], 0.045, 1500);
|
||||
bell(end + 0.2, er + 24, 0.06);
|
||||
bell(end + 0.55, er + 24 + 7, 0.04, 0.2);
|
||||
|
||||
// ---- soft accents on the scene changes ----
|
||||
for (const c of CUTS) {
|
||||
const [root, iv] = CH[SCENE_CHORD[c.id]];
|
||||
swell(c.t, 0.9, 0.025);
|
||||
bell(c.t + 0.02, root + 24 + iv[2], 0.045, (c.t * 7) % 2 - 1 > 0 ? 0.15 : -0.15);
|
||||
}
|
||||
/** A deep, slow boom (the rejection): a sine falling from 70 Hz plus low noise, a long room. */
|
||||
function boom(t, g = 0.5) {
|
||||
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(72, t); o.frequency.exponentialRampToValueAtTime(30, t + 1.6);
|
||||
const v = ctx.createGain(); env(v, t, 0.005, g, 2.2); o.connect(v); v.connect(master);
|
||||
const n = noiseSrc(t, 1.2); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 240;
|
||||
const nv = ctx.createGain(); env(nv, t, 0.004, g * 0.5, 0.9); n.connect(lp); lp.connect(nv); nv.connect(master); nv.connect(verbIn);
|
||||
}
|
||||
function thud(t, g = 0.18) {
|
||||
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(110, t); o.frequency.exponentialRampToValueAtTime(48, t + 0.15);
|
||||
const v = ctx.createGain(); env(v, t, 0.002, g, 0.28); o.connect(v); v.connect(sfx);
|
||||
const n = noiseSrc(t, 0.12); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900;
|
||||
const nv = ctx.createGain(); env(nv, t, 0.002, g * 0.35, 0.09); n.connect(lp); lp.connect(nv); nv.connect(sfx); nv.connect(sfxVerb);
|
||||
}
|
||||
/** The mouse click on Submit. */
|
||||
function click(t) {
|
||||
tick(t, 0.09, 4200); tick(t + 0.055, 0.05, 2800);
|
||||
}
|
||||
|
||||
// ---- UI sounds ----
|
||||
// ---------- calm: the game is done ----------
|
||||
{
|
||||
const [r, iv] = CH.Dmaj9;
|
||||
pad(0, S.calm[1] + 0.2, [r, r + iv[1], r + iv[2], r + iv[3]], 0.032, 900, 1.6, 1.2);
|
||||
pad(0, S.calm[1] + 0.2, [r - 12], 0.02, 300, 1.6, 1.0);
|
||||
const tones = [62, 66, 69, 73, 74, 73, 69, 66];
|
||||
for (let t = 0.5, k = 0; t < S.calm[1] - 0.1; t += 2 * STEP, k++) pluck(t, tones[k % tones.length], 0.036, k % 2 ? 0.25 : -0.25, 1.6);
|
||||
}
|
||||
|
||||
// ---------- tension: the stores pile up ----------
|
||||
{
|
||||
const [a, b] = S.tension;
|
||||
const [r, iv] = CH.Bm;
|
||||
pad(a - 0.3, b, [r, r + iv[1], r + iv[2], r + 12], 0.03, 700, 1.0, 0.4, master, 2600);
|
||||
// a b9 creeps in over the last two seconds
|
||||
pad(b - 2.2, b, [r + 13], 0.016, 1200, 1.8, 0.3);
|
||||
// the low pulse: eighths on B, louder and brighter towards the rejection
|
||||
for (let t = a, k = 0; t < b - 0.01; t += STEP / 2, k++) {
|
||||
const p = (t - a) / (b - a);
|
||||
bass(t, k % 8 === 7 ? 42 : 35, STEP / 2 * 0.9, 0.05 + 0.07 * p, 260 + 900 * p);
|
||||
}
|
||||
// a clock ticking in sixteenths over the second half
|
||||
for (let t = a + 3, k = 0; t < b - 0.01; t += STEP / 4, k++) tick(t, (k % 4 === 0 ? 0.03 : 0.016) * (0.6 + (t - a) / (b - a) * 0.6), k % 4 === 0 ? 5200 : 6800);
|
||||
riser(b, 3, 0.07, 300, 6000);
|
||||
}
|
||||
|
||||
// ---------- dark: one missing line ----------
|
||||
{
|
||||
const [a, b] = S.dark;
|
||||
boom(a, 0.55);
|
||||
const v = pad(a, b - 1.2, [35, 38, 42, 47], 0.04, 420, 0.1, 1.6, master, 260);
|
||||
void v;
|
||||
pad(a, b - 1.4, [59, 60], 0.008, 1800, 1.0, 1.4); // a faint, uneasy cluster above
|
||||
}
|
||||
|
||||
// ---------- near silence: the request ----------
|
||||
{
|
||||
const [a, b] = S.silence;
|
||||
// room tone: very quiet, dark noise
|
||||
const n = noiseSrc(a, b - a); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
|
||||
const v = ctx.createGain(); v.gain.setValueAtTime(0, a); v.gain.linearRampToValueAtTime(0.012, a + 0.8); v.gain.setValueAtTime(0.012, b - 0.6); v.gain.linearRampToValueAtTime(0, b);
|
||||
n.connect(lp); lp.connect(v); v.connect(master);
|
||||
pluck(a + 0.5, 66, 0.022, 0, 2.5);
|
||||
// the swell into the drop
|
||||
riser(b, 1.5, 0.08, 250, 7000);
|
||||
pad(b - 1.5, b, [62, 69, 74], 0.02, 600, 1.4, 0.05, master, 3000);
|
||||
}
|
||||
|
||||
// ---------- drive: the montage and the scan ----------
|
||||
{
|
||||
const [a, b] = S.drive;
|
||||
const layer = id => scene(id).start;
|
||||
const L = { hats: layer('shots'), claps: layer('products'), sixteenths: layer('forms'), lead: layer('consoles') };
|
||||
for (let t = a, k = 0; t < b - 0.01; t += STEP, k++) {
|
||||
const bar = Math.floor((t - a) / BAR), beat = k % 4;
|
||||
const [root, iv] = CH[PROG[bar % 4]];
|
||||
kick(t, beat === 0 ? 0.55 : 0.46);
|
||||
if (t >= L.claps && (beat === 1 || beat === 3)) clap(t, 0.11);
|
||||
if (t >= L.hats) hat(t + STEP / 2, 0.028, beat === 3);
|
||||
if (t >= L.sixteenths) { hat(t + STEP / 4, 0.014); hat(t + STEP * 3 / 4, 0.014); }
|
||||
// the bass: root eighths, the fifth on the last one of the bar
|
||||
const bm = root - 12 + (root < 46 ? 12 : 0);
|
||||
bass(t, bm, STEP / 2 * 0.85, 0.1);
|
||||
bass(t + STEP / 2, beat === 3 ? bm + 7 : bm, STEP / 2 * 0.85, 0.085);
|
||||
// the arpeggio: sixteenths over the chord, brighter layer by layer
|
||||
const tones = iv.slice(0, 4).map(x => root + 24 + x);
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const m = tones[(k * 4 + j) % tones.length];
|
||||
pluck(t + j * STEP / 4, m, (t >= L.lead ? 0.034 : 0.026) * (j === 0 ? 1.2 : 1), (j % 2 ? 0.3 : -0.3), 0.35, drive);
|
||||
}
|
||||
if (beat === 0) pad(t, t + BAR - 0.05, [root + 12, root + 12 + iv[1], root + 12 + iv[2]], t >= L.lead ? 0.022 : 0.016, t >= L.lead ? 2600 : 1600, 0.05, 0.12, drive);
|
||||
if (beat === 0 && t >= L.lead) pluck(t, root + 36 + iv[2], 0.03, 0, 1.4, drive);
|
||||
}
|
||||
// a cymbal-like swell into each montage cut, a big one into the drop and into the stop
|
||||
for (const id of MONTAGE.slice(1).concat(['scan'])) riser(scene(id).start, 0.5, 0.03, 2000, 9000, drive);
|
||||
riser(b, 2, 0.06, 500, 8000, drive);
|
||||
}
|
||||
|
||||
// ---------- stop: one button is yours ----------
|
||||
{
|
||||
const [a, b] = S.stop;
|
||||
// the last hit rings out into the room, then only a quiet low pulse, slower than the beat
|
||||
bell(a, 74 + 12, 0.035);
|
||||
for (let t = a + 1.0; t < b - 0.3; t += 1.0) {
|
||||
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(38);
|
||||
const v = ctx.createGain(); env(v, t, 0.02, 0.025 * Math.min(1, (t - a) / 3), 0.5); o.connect(v); v.connect(master); o.start(t); o.stop(t + 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- resolve: the click, waiting for review, the end card ----------
|
||||
{
|
||||
const [a, b] = S.resolve;
|
||||
click(a);
|
||||
const [r, iv] = CH.Dmaj9;
|
||||
pad(a + 0.08, b + 0.6, [r - 12, r, r + iv[1], r + iv[2], r + iv[3], r + iv[4]], 0.048, 1600, 0.9, 1.0);
|
||||
pad(a + 0.08, b + 0.6, [r - 24], 0.03, 200, 1.2, 1.0);
|
||||
bell(a + 0.3, r + 24, 0.06);
|
||||
bell(a + 0.65, r + 24 + 7, 0.045, 0.2);
|
||||
bell(a + 1.0, r + 24 + 14, 0.035, -0.2);
|
||||
// a slow arpeggio returns (the calm of the opening, now open)
|
||||
const tones = [74, 78, 81, 85, 86, 85, 81, 78];
|
||||
for (let t = a + 1.5, k = 0; t < b - 1.2; t += STEP, k++) pluck(t, tones[k % tones.length], 0.03, k % 2 ? 0.25 : -0.25, 1.4);
|
||||
// the end card: one more bell as the logo lands
|
||||
const e = scene('end').start;
|
||||
bell(e + 0.3, r + 24, 0.05);
|
||||
bell(e + 0.55, r + 31, 0.035, 0.2);
|
||||
}
|
||||
|
||||
// ---------- soft accents on the story cuts ----------
|
||||
for (const c of CUTS) {
|
||||
if (MONTAGE.includes(c.id) || c.id === 'scan') continue;
|
||||
if (c.id === 'reject' || c.id === 'builds') continue; // the boom and the drop are the accents
|
||||
bell(c.t + 0.01, c.id === 'pile' ? 71 : 74, 0.03, 0.1);
|
||||
}
|
||||
|
||||
// ---------- UI sounds ----------
|
||||
for (const x of ticks()) {
|
||||
if (x.kind === 'tiny') tick(x.t, 0.022, 2800);
|
||||
else if (x.kind === 'soft') { tick(x.t, 0.03, 2400); tone(x.t, 86, 0.012, 0.18); }
|
||||
else if (x.kind === 'mail') { tone(x.t, 81, 0.04, 0.2); tone(x.t + 0.09, 88, 0.035, 0.4); }
|
||||
else if (x.kind === 'low') { tone(x.t, 50, 0.07, 0.5, 'triangle'); tone(x.t, 57, 0.02, 0.3); }
|
||||
else if (x.kind === 'ask') { tone(x.t, 83, 0.035, 0.3); tone(x.t + 0.14, 81, 0.03, 0.45); }
|
||||
else if (x.kind === 'good') { const m = [74, 78, 81][x.step] ?? 81; tone(x.t, m, 0.04, 0.6, 'sine', -0.2 + 0.2 * x.step); tone(x.t, m + 12, 0.012, 0.4); }
|
||||
if (x.kind === 'tiny') tick(x.t, 0.02, 2800);
|
||||
else if (x.kind === 'thud') thud(x.t, 0.12 + x.i * 0.015);
|
||||
else if (x.kind === 'low') tone(x.t, 38, 0.05, 0.9, 'triangle');
|
||||
else if (x.kind === 'send') { tone(x.t, 81, 0.03, 0.3); tone(x.t + 0.1, 86, 0.025, 0.6); }
|
||||
else if (x.kind === 'good') { const m = [74, 76, 78, 81, 83][x.step] ?? 86; tone(x.t, m + 12, 0.045, 0.5, 'sine', -0.3 + 0.15 * x.step); tone(x.t, m + 24, 0.012, 0.3); }
|
||||
else if (x.kind === 'clean') { tone(x.t, 86, 0.04, 0.8); tone(x.t + 0.08, 90, 0.03, 0.9); tone(x.t + 0.16, 93, 0.025, 1.0); }
|
||||
}
|
||||
for (const t of keyTimes()) key(t);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// -16 LUFS. Frames are captured at 60 fps and blended in pairs down to 30 fps (a 180° shutter): slow moves stay smooth.
|
||||
// bun media/teaser/render.ts master 1920x1080, sound, poster, 1280x720 web version -> media/teaser/out/
|
||||
// bun media/teaser/render.ts --stills 1,9.5,30 PNG stills only (out/stills/), quick layout check
|
||||
// bun media/teaser/render.ts --sound the music and sound only (out/music.wav)
|
||||
// bun media/teaser/render.ts --desktop capture the desktop app frames again first (desktop.ts)
|
||||
// bun media/teaser/render.ts --serve preview server: open /index.html?play=1 (or ?t=12.5)
|
||||
// Needs ffmpeg and Playwright (devDependency; Playwright's Chromium or an installed Google Chrome), and the demo game's
|
||||
|
|
@ -21,7 +22,7 @@ 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 770 kbps video for 62 s, as v6 had for 55.5 s). */
|
||||
/** The web version: 1280x720 H.264, two-pass to about this many bytes (about 830 kbps video for 58 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}`);
|
||||
|
|
@ -33,7 +34,8 @@ function sh(cmd: string[], input?: Uint8Array): string {
|
|||
}
|
||||
const ff = (...a: string[]): string => sh(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', ...a]);
|
||||
|
||||
/** The screenshot grid: the demo game's real store renders, the first image of every size in every language. */
|
||||
/** The screenshot tiles: the demo game's real store renders, the first image of every size in every language; and the
|
||||
* game's own screens. */
|
||||
function prepAssets(): void {
|
||||
const dir = join(ASSETS, 'shots');
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
|
@ -47,8 +49,18 @@ function prepAssets(): void {
|
|||
ff('-i', src, '-vf', size === 'feature' ? 'scale=600:-2:flags=lanczos' : 'scale=-2:360:flags=lanczos', '-q:v', '3', join(dir, `${lang}-${size}.jpg`));
|
||||
n++;
|
||||
}
|
||||
// the game's own screens (its store captures, before any framing): the opening scene
|
||||
const game = join(ASSETS, 'game');
|
||||
rmSync(game, { recursive: true, force: true });
|
||||
mkdirSync(game, { recursive: true });
|
||||
for (const id of ['play', 'combo', 'map', 'stars']) {
|
||||
const src = join(ROOT, 'examples/demo-game/store/shots/frames/en/phone', `${id}.png`);
|
||||
if (!existsSync(src)) { missing.push(`frames/en/phone/${id}`); continue; }
|
||||
ff('-i', src, '-vf', 'scale=-2:1100:flags=lanczos', '-q:v', '2', join(game, `${id}.jpg`));
|
||||
n++;
|
||||
}
|
||||
if (missing.length) throw new Error(`demo game renders missing (${missing.slice(0, 4).join(', ')}${missing.length > 4 ? ', ...' : ''}): run its shots capture, fonts and render first (examples/demo-game/README.md)`);
|
||||
log(`assets: ${n} demo game renders (examples/demo-game/store/renders)`);
|
||||
log(`assets: ${n} demo game renders and screens (examples/demo-game/store)`);
|
||||
}
|
||||
|
||||
function serve(): ReturnType<typeof Bun.serve> {
|
||||
|
|
@ -75,7 +87,8 @@ async function main(): Promise<void> {
|
|||
const browser = await launch(ROOT, { headless: true });
|
||||
try {
|
||||
const stills = opt('stills');
|
||||
{
|
||||
const soundOnly = args.includes('--sound');
|
||||
if (!soundOnly) {
|
||||
const f = FORMAT;
|
||||
const page = await browser.newPage({ viewport: { width: f.w, height: f.h }, deviceScaleFactor: 1 });
|
||||
const errors: string[] = [];
|
||||
|
|
@ -131,17 +144,21 @@ async function main(): Promise<void> {
|
|||
const raw = join(OUT, 'music-raw.wav'), music = join(OUT, 'music.wav');
|
||||
await Bun.write(raw, Buffer.from(wav.base64, 'base64'));
|
||||
const dur = wav.seconds;
|
||||
// two-pass loudnorm to -16 LUFS / -1.5 dBTP (calm music), then a short fade out
|
||||
// loudness: measured with loudnorm (EBU R128), then ONE gain to about -16 LUFS that keeps the true peak at or below
|
||||
// -1.5 dBTP, and a short fade out. No dynamic normalization: the near silence and the stop are part of the story
|
||||
const m = Bun.spawnSync(['ffmpeg', '-hide_banner', '-i', raw, '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json', '-f', 'null', '-'], { stderr: 'pipe' });
|
||||
const j = JSON.parse(m.stderr.toString().match(/\{[\s\S]*?\}/)![0]) as Record<string, string>;
|
||||
ff('-i', raw, '-af', `loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=${j.input_i}:measured_TP=${j.input_tp}:measured_LRA=${j.input_lra}:measured_thresh=${j.input_thresh}:offset=${j.target_offset}:linear=true,afade=t=out:st=${(dur - 1.5).toFixed(2)}:d=1.5,aresample=48000`,
|
||||
const gain = Math.min(-16 - Number(j.input_i), -1.5 - Number(j.input_tp));
|
||||
log(`sound: ${j.input_i} LUFS, LRA ${j.input_lra} LU, true peak ${j.input_tp} dBTP; gain ${gain.toFixed(2)} dB -> ${(Number(j.input_i) + gain).toFixed(1)} LUFS`);
|
||||
ff('-i', raw, '-af', `volume=${gain.toFixed(2)}dB,afade=t=out:st=${(dur - 1.5).toFixed(2)}:d=1.5,aresample=48000`,
|
||||
'-c:a', 'pcm_s16le', '-ar', '48000', '-ac', '2', music);
|
||||
{
|
||||
if (!soundOnly) {
|
||||
const silent = join(OUT, `teaser-${FORMAT.w}x${FORMAT.h}-silent.mp4`), out = join(OUT, `teaser-${FORMAT.w}x${FORMAT.h}.mp4`);
|
||||
if (!existsSync(silent)) throw new Error(`${silent} missing`);
|
||||
ff('-i', silent, '-i', music, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2', '-shortest', '-movflags', '+faststart', out);
|
||||
log(`${out} (${(statSync(out).size / 1e6).toFixed(1)} MB)`);
|
||||
}
|
||||
if (soundOnly) return;
|
||||
|
||||
// web version: 1280x720 H.264, about 6 MB (two-pass), plus its poster
|
||||
const h = join(OUT, 'teaser-1920x1080.mp4');
|
||||
|
|
|
|||
|
|
@ -1,182 +1,221 @@
|
|||
// 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.
|
||||
// Teaser timeline (v8, a story): the single source of truth for the picture (teaser.js) and the sound (music.js).
|
||||
// 58 s, 16:9. Little text: one short line per scene at most, big and calm; the music carries the arc
|
||||
// (calm -> tension -> near silence -> a driving build -> stop -> resolve). Every cut lands on a half-second beat.
|
||||
//
|
||||
// Every CLI line shown here is real output; tests/teaser.test.ts runs each command and checks every line:
|
||||
// init `storebound init` in an empty folder (the clock prefix left out)
|
||||
// plan `storebound release plan 1.0.0` in that folder, the stores answered by tests/helpers/teaser-store-mock.ts
|
||||
// (a brand-new app record: nothing on either store yet); the "-> fix" lines left out
|
||||
// products `storebound play products --dry` and `storebound catalog diff` in that folder with two products added
|
||||
// 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; 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.
|
||||
// game 0-5 the demo game's real screens, a gentle drift "The game is done."
|
||||
// pile 5-11 real store work piles up: App Privacy answers, Data safety CSV, "Then come the stores."
|
||||
// screenshot sizes, price sheets
|
||||
// reject 11-15 one real scan finding (5.1.1(v)) grows to fill the frame, it darkens "One missing line, and you start over."
|
||||
// ask 15-19 near silence: one request typed to the AI agent "So I handed it all to my AI."
|
||||
// builds..consoles 19-37 the montage, one word at the edge: Builds. / Screenshots, every size. / Products, 175
|
||||
// countries. / Forms. / Consoles.
|
||||
// scan 37-42 `storebound scan`: each finding turns green, the re-scan is clean "Nothing left for a reviewer to catch."
|
||||
// button 42-48 the desktop app's Send for review, the pointer resting on Submit; the music stops "One button is yours."
|
||||
// waiting 48-53 the click, then "Waiting for review" (the app's own state); the music resolves
|
||||
// end 53-58 the logo, "From an empty folder to Submit.", "Open source · storebound.dev"
|
||||
//
|
||||
// Every CLI line shown is real output; tests/teaser.test.ts runs each command and checks every line:
|
||||
// pile `storebound console plan apple-app-privacy` (answer sheet, steps), `storebound play data-safety --dry`
|
||||
// (the CSV; its row count from `storebound check`), `storebound prices ppp --offline --dry` (the price sheet),
|
||||
// all in a fresh `storebound init` folder with two products
|
||||
// reject `storebound scan` of tests/fixtures/scan/teaser
|
||||
// builds `storebound apple build --dry` and `storebound play build --dry` in a Capacitor project
|
||||
// products `storebound play products --dry` and `storebound catalog diff` (the teaser store double)
|
||||
// forms the declarations proposal of `storebound scan` and the Data safety rows
|
||||
// consoles `storebound console plan apple-app-privacy`, then the desktop app's plan panel with that real plan
|
||||
// scan `storebound scan` of tests/fixtures/scan/teaser, then of teaser-fixed
|
||||
// The game screens are examples/demo-game's own captures (store/shots/frames), the screenshot tiles its real renders,
|
||||
// the desktop frames the app in fixture mode (desktop.ts).
|
||||
|
||||
export const FPS = 30;
|
||||
/** 16:9 only: the master; render.ts also makes the 1280x720 web version from it. */
|
||||
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], ['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 }]; }, []);
|
||||
/** Scenes: id, length (s), the transition into it (s, centred on the cut; short = a cut on the beat). */
|
||||
const LENGTHS = [
|
||||
['game', 5, 0], ['pile', 6, 0.6], ['reject', 4, 0.5], ['ask', 4, 0.8],
|
||||
['builds', 4, 0.1], ['shots', 4, 0.1], ['products', 3.5, 0.1], ['forms', 3.5, 0.1], ['consoles', 3, 0.1],
|
||||
['scan', 5, 0.1], ['button', 6, 0.5], ['waiting', 5, 0.1], ['end', 5, 0.8],
|
||||
];
|
||||
export const SCENES = LENGTHS.reduce((a, [id, len, x]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len, x }]; }, []);
|
||||
export const DURATION = SCENES.at(-1).end;
|
||||
export const DISSOLVE = 0.4;
|
||||
export const scene = id => SCENES.find(s => s.id === id);
|
||||
/** The montage: one word or short phrase at the edge, cuts on the beat. */
|
||||
export const MONTAGE = ['builds', 'shots', 'products', 'forms', 'consoles'];
|
||||
|
||||
/**
|
||||
* One camera move per scene, over the whole scene: from -> to, a quick ease-out that settles before the cut.
|
||||
* z = zoom, x / y = pan in px (applied to the scene's content, never to the caption).
|
||||
*/
|
||||
/** One camera move per scene: from -> to (z = zoom, x / y = pan in px), a slow ease (story) or a quick push (montage). */
|
||||
export const CAMERA = {
|
||||
hook: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: 6 } },
|
||||
init: { from: { z: 1.0, x: 20, y: 0 }, to: { z: 1.06, x: -40, y: 0 } },
|
||||
plan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: -24 } },
|
||||
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 } },
|
||||
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 } },
|
||||
game: { from: { z: 1.0, x: 40, y: 10 }, to: { z: 1.07, x: -40, y: -6 } },
|
||||
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.12, x: 0, y: 10 } },
|
||||
reject: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
ask: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.04, x: 0, y: 0 } },
|
||||
builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: -20, y: -10 } },
|
||||
shots: { from: { z: 1.04, x: 30, y: 0 }, to: { z: 1.0, x: -30, y: 0 } },
|
||||
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: 20, y: -10 } },
|
||||
forms: { from: { z: 1.04, x: -20, y: 0 }, to: { z: 1.0, x: 20, y: 0 } },
|
||||
consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
scan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: -10 } },
|
||||
button: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
waiting: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
end: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.03, x: 0, y: 0 } },
|
||||
};
|
||||
|
||||
/** Captions: one per scene, at the top. *word* = highlighted. Lines are set as written. */
|
||||
/** Captions: at most one short line per scene. top = the story line, big and centred at the top; edge = the montage
|
||||
* word, bottom left. at / until: seconds into the scene (fade in from `at`, gone at `until`). *word* = highlighted. */
|
||||
export const CAPTIONS = [
|
||||
{ id: 'hook', lines: ['Your app is done. The stores are *not.*'], hl: 'red' },
|
||||
{ id: 'init', lines: ['storebound takes it from an *empty folder.*'] },
|
||||
{ id: 'plan', lines: ['You ask once. It plans *every step.*'] },
|
||||
{ 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: 'resubmit', lines: ['Rejected? Fix it, *resubmit in one click.*'] },
|
||||
{ id: 'submit', lines: ['You press *Submit.* That\'s it.'] },
|
||||
{ id: 'game', pos: 'top', text: 'The game is *done.*', at: 0.6, until: 4.85 },
|
||||
{ id: 'pile', pos: 'top', text: 'Then come *the stores.*', at: 0.5, until: 5.9 },
|
||||
{ id: 'reject', pos: 'top', text: 'One missing line, and you *start over.*', at: 0.35, until: 3.95, hl: 'red' },
|
||||
{ id: 'ask', pos: 'top', text: 'So I handed it all *to my AI.*', at: 0.35, until: 3.95 },
|
||||
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 0.05, until: 3.95 },
|
||||
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 0.05, until: 3.95 },
|
||||
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 0.05, until: 3.45 },
|
||||
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 0.05, until: 3.45 },
|
||||
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 0.05, until: 2.95 },
|
||||
{ id: 'scan', pos: 'top', text: 'Nothing left for a reviewer *to catch.*', at: 1.5, until: 4.95, hl: 'green' },
|
||||
{ id: 'button', pos: 'top', text: 'One button is *yours.*', at: 0.9, until: 5.9 },
|
||||
];
|
||||
/** Caption fade: in over CAP_IN from CAP_DELAY after the scene starts; out over CAP_OUT, ending CAP_END before it ends. */
|
||||
export const CAP_DELAY = 0.1, CAP_IN = 0.3, CAP_OUT = 0.3, CAP_END = 0.05;
|
||||
/** When each caption is fully on screen (tests: at least 3 s). */
|
||||
/** Fades (s): the story lines slow and soft, the montage words quick. */
|
||||
export const CAP_FADE = { top: [0.5, 0.4], edge: [0.15, 0.15] };
|
||||
/** When each caption is fully on screen (tests: at least 2.5 s, inside its scene). */
|
||||
export function captionTimes() {
|
||||
return CAPTIONS.map(c => {
|
||||
const s = scene(c.id);
|
||||
const at = s.start + CAP_DELAY, from = at + CAP_IN, to = s.end - CAP_END - CAP_OUT, gone = s.end - CAP_END;
|
||||
return { id: c.id, text: c.lines.join(' ').replace(/\*/g, ''), at, from, to, gone, hold: to - from };
|
||||
const s = scene(c.id), [fin, fout] = CAP_FADE[c.pos];
|
||||
const at = s.start + c.at, from = at + fin, to = s.start + c.until - fout, gone = s.start + c.until;
|
||||
return { id: c.id, text: c.text.replace(/\*/g, ''), pos: c.pos, at, from, to, gone, hold: to - from };
|
||||
});
|
||||
}
|
||||
/** The dark veil over the picture (0-1) at time t: it falls over the rejection, holds through the request, lifts on
|
||||
* the first beat of the montage. */
|
||||
export function veil(t) {
|
||||
const r = scene('reject'), a = scene('ask');
|
||||
if (t < r.start + 0.4) return 0;
|
||||
if (t < r.end) return 0.72 * Math.min(1, (t - r.start - 0.4) / 3.2);
|
||||
if (t < a.end - 0.05) return 0.72 - 0.12 * Math.min(1, (t - a.start) / 1.5);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------- 1 hook: the app is done, the stores' to-do list, one rejection mail ----------
|
||||
export const APP_NAME = 'Prism Puddle';
|
||||
/** The stores' to-do list (line by line at reading speed). at = seconds into the scene. */
|
||||
export const TODO = [
|
||||
{ at: 0.35, text: '2 store consoles' },
|
||||
{ at: 0.7, text: 'dozens of forms' },
|
||||
{ at: 1.05, text: '7 screenshot sizes × 14 languages' },
|
||||
{ at: 1.4, text: 'prices in 175 countries' },
|
||||
];
|
||||
/** A generic, made-up inbox row (no mail-app branding, no real names or addresses): fades in over MAIL_IN from `at`,
|
||||
* then holds to the cut. */
|
||||
export const MAIL_IN = 0.4;
|
||||
export const MAIL = { at: 1.7, from: 'App Store Connect', date: '9:41 AM', subject: "There's an issue with your Prism Puddle submission", guideline: 'Guideline 2.1 · Information Needed' };
|
||||
// ---------- 1 game: the demo game's real screens ----------
|
||||
/** examples/demo-game/store/shots/frames/en/phone/<id>.png (the game's own captures), left to right. */
|
||||
export const GAME_SCREENS = ['play', 'combo', 'map', 'stars'];
|
||||
export const GAME_AT = 0.15, GAME_STEP = 0.22;
|
||||
|
||||
// ---------- 2 init: an empty folder, `storebound init` ----------
|
||||
export const FOLDER = 'my-app';
|
||||
export const INIT_CMD = 'bunx storebound init';
|
||||
export const INIT_TYPE_AT = 0.35, INIT_CPS = 22, INIT_OUT_AT = 1.45, INIT_STEP = 0.12;
|
||||
/** Real output of `storebound init` (clock prefix left out). file = the file the line creates (the folder list). */
|
||||
export const INIT_LINES = [
|
||||
'[init] create storebound.config.ts',
|
||||
'[init] create store/listing.md',
|
||||
'[init] create store/appstore.md',
|
||||
'[init] create store/shots/copy.json',
|
||||
'[init] create store/app-privacy.md',
|
||||
'[init] create store/declarations.md',
|
||||
'[init] create store/privacy-policy.html',
|
||||
'[init] create .storebound/credentials.json',
|
||||
'[init] create .storebound/testers.csv',
|
||||
'[init] .gitignore: added .storebound/',
|
||||
// ---------- 2 pile: the store work piles up ----------
|
||||
/** Real lines (see the header). */
|
||||
export const PRIVACY_ANSWERS = [
|
||||
' [ok] Do you or your third-party partners collect data from this app?: Yes -> pick "Yes, we collect data from this app" @ the Data Collection question (Get Started)',
|
||||
' [ok] Data type: Contact Info > Name: No -> untick "Name" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Contact Info > Email Address: Yes -> tick "Email Address" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Contact Info > Phone Number: No -> untick "Phone Number" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Contact Info > Physical Address: No -> untick "Physical Address" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Contact Info > Other User Contact Info: No -> untick "Other User Contact Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Health & Fitness > Health: No -> untick "Health" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Health & Fitness > Fitness: No -> untick "Fitness" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Financial Info > Payment Info: No -> untick "Payment Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Financial Info > Credit Info: No -> untick "Credit Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Financial Info > Other Financial Info: No -> untick "Other Financial Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
' [ok] Data type: Location > Precise Location: No -> untick "Precise Location" @ the Data Collection dialog (Edit next to Data Types): the data type list',
|
||||
];
|
||||
export const PRIVACY_STEPS = [
|
||||
' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar).',
|
||||
' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]',
|
||||
' 5. "Email Address" step 1, purposes: tick and untick the 6 checkboxes as answered, then Next. [click: "Next"]',
|
||||
' 6. "Email Address" step 2, linked to the user: pick the radio, then Next. [click: "Next"]',
|
||||
' 8. "Email Address" step 5, tracking: pick the radio, then Save (a draft). Never "Definitions and Examples". The dialog closes; the row shows Edit and the summary. [click: "Save"]',
|
||||
];
|
||||
export const DATA_SAFETY_CSV = [
|
||||
'PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA,,TRUE,REQUIRED,Does your app collect or share any of the required user data types?',
|
||||
'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT,,TRUE,MAYBE_REQUIRED,Is all of the user data collected by your app encrypted in transit?',
|
||||
'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS,PSL_ACM_USER_ID_PASSWORD,TRUE,MULTIPLE_CHOICE,Which of the following methods of account creation does your app support? Select all that apply / Username and password',
|
||||
'PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,MAYBE_REQUIRED,Add a link that users can use to request that their account and associated data is deleted',
|
||||
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,TRUE,SINGLE_CHOICE,Do you provide a way for users to request that their data is deleted? / Yes',
|
||||
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,,SINGLE_CHOICE,Do you provide a way for users to request that their data is deleted? / No',
|
||||
'PSL_DATA_DELETION_URL,,https://example.com/delete-account,MAYBE_REQUIRED,Delete data URL',
|
||||
'PSL_DATA_TYPES_PERSONAL,PSL_NAME,,MULTIPLE_CHOICE,Personal info / Name',
|
||||
'PSL_DATA_TYPES_PERSONAL,PSL_EMAIL,TRUE,MULTIPLE_CHOICE,Personal info / Email address',
|
||||
'PSL_DATA_TYPES_PERSONAL,PSL_USER_ACCOUNT,TRUE,MULTIPLE_CHOICE,Personal info / User IDs',
|
||||
'PSL_DATA_TYPES_PERSONAL,PSL_ADDRESS,,MULTIPLE_CHOICE,Personal info / Address',
|
||||
'PSL_DATA_TYPES_PERSONAL,PSL_PHONE,,MULTIPLE_CHOICE,Personal info / Phone number',
|
||||
'PSL_DATA_TYPES_PERSONAL,PSL_RACE_ETHNICITY,,MULTIPLE_CHOICE,Personal info / Race and ethnicity',
|
||||
];
|
||||
/** Data safety CSV rows `storebound check` counts for the config. */
|
||||
export const DECL_TOTAL = 768;
|
||||
export const PRICE_ROWS = [
|
||||
'com.example.app.no_ads,apple,AE,AED,6.99,,manual', 'com.example.app.no_ads,apple,AF,USD,0.89,,manual',
|
||||
'com.example.app.no_ads,apple,AG,USD,1.99,,manual', 'com.example.app.no_ads,apple,AL,USD,0.99,,manual',
|
||||
'com.example.app.no_ads,apple,AM,USD,0.99,,manual', 'com.example.app.no_ads,apple,AO,USD,0.99,,manual',
|
||||
'com.example.app.no_ads,apple,AR,USD,0.99,,manual', 'com.example.app.no_ads,apple,AT,EUR,1.99,,manual',
|
||||
'com.example.app.no_ads,apple,AU,AUD,3.99,,manual', 'com.example.app.no_ads,apple,AZ,USD,0.89,,manual',
|
||||
'com.example.app.no_ads,apple,BA,EUR,0.99,,manual', 'com.example.app.no_ads,apple,BB,USD,2.99,,manual',
|
||||
'com.example.app.no_ads,apple,BE,EUR,1.99,,manual', 'com.example.app.no_ads,apple,BF,USD,0.99,,manual',
|
||||
'com.example.app.no_ads,apple,BG,EUR,0.99,,manual', 'com.example.app.no_ads,apple,BH,USD,0.99,,manual',
|
||||
'com.example.app.no_ads,apple,BJ,USD,0.99,,manual', 'com.example.app.no_ads,apple,BM,USD,3.99,,manual',
|
||||
'com.example.app.no_ads,apple,BN,USD,0.99,,manual', 'com.example.app.no_ads,apple,BO,USD,0.99,,manual',
|
||||
'com.example.app.no_ads,apple,BR,BRL,7.99,,manual', 'com.example.app.no_ads,apple,BS,USD,2.99,,manual',
|
||||
];
|
||||
export const PRICE_CMD = 'prices ppp --offline --dry --store apple --only com.example.app.no_ads';
|
||||
/** The cards that drop onto the pile, faster and faster. at = seconds into the scene; x / y = the card's centre
|
||||
* (px), w = width, rot = degrees. kind: what it shows. */
|
||||
export const PILE = [
|
||||
{ at: 0.25, kind: 'privacy', x: 700, y: 560, w: 1000, rot: -3 },
|
||||
{ at: 1.25, kind: 'csv', x: 1220, y: 600, w: 980, rot: 2.5 },
|
||||
{ at: 2.15, kind: 'shots', x: 860, y: 640, w: 900, rot: -1.5 },
|
||||
{ at: 2.95, kind: 'prices', x: 1180, y: 560, w: 760, rot: 4 },
|
||||
{ at: 3.6, kind: 'steps', x: 760, y: 690, w: 1040, rot: 2 },
|
||||
{ at: 4.15, kind: 'csv', x: 1060, y: 610, w: 1000, rot: -4.5, from: 7 },
|
||||
{ at: 4.6, kind: 'shots', x: 940, y: 560, w: 940, rot: 3.5, lang: 'ja' },
|
||||
{ at: 5.0, kind: 'prices', x: 820, y: 640, w: 800, rot: -2.5, from: 11 },
|
||||
{ at: 5.35, kind: 'privacy', x: 1040, y: 600, w: 1060, rot: 1, from: 6 },
|
||||
];
|
||||
/** The card headers (what each card is; no claims). */
|
||||
export const PILE_TITLES = { privacy: 'App Privacy · answer sheet', steps: 'App Privacy · console steps', csv: `Data safety · ${DECL_TOTAL} rows`, shots: 'Screenshots', prices: 'Prices · every region' };
|
||||
|
||||
// ---------- 3 plan: the agent prompt, `storebound release plan` ----------
|
||||
export const PROMPT = 'Get my app ready for both stores.';
|
||||
export const PROMPT_AT = 0.2, PROMPT_CPS = 40;
|
||||
export const PLAN_CMD = 'storebound release plan 1.0.0';
|
||||
export const PLAN_CMD_AT = 1.25, PLAN_AT = 1.55, PLAN_STEP = 0.13;
|
||||
/** Real lines of the plan (a brand-new app record on both stores); the "-> fix" lines and some steps left out. */
|
||||
export const PLAN_LINES = [
|
||||
'Example App 1.0.0',
|
||||
' both stores (fix first):',
|
||||
' 1. CONFIG App Review needs a demo account (sign-in required) · add reviewDemoAccount to .storebound/credentials.json · review-access skill',
|
||||
' 2. CONFIG missing file: store/icon/icon.png',
|
||||
' App Store: not on the store',
|
||||
' 5. STORE 1.0.0 is not on App Store yet: upload a build (Xcode / Transporter), then create the version with its texts',
|
||||
' 6. BLOCKER no App Store version being prepared',
|
||||
' 12. BLOCKER no app price schedule',
|
||||
' Google Play: not on the store',
|
||||
' 16. STORE 1.0.0 is not on Google Play yet: upload the AAB to a testing track',
|
||||
' 20. BLOCKER no release on any track',
|
||||
];
|
||||
/** Steps in the whole plan (the numbered lines of the real output). */
|
||||
export const PLAN_STEPS = 26;
|
||||
export const PLAN_PILL = `*${PLAN_STEPS}* steps · both stores · in fix order`;
|
||||
// ---------- 3 reject: one finding grows to fill the frame ----------
|
||||
/** The scan's own lines for the account-deletion finding (tests/fixtures/scan/teaser). */
|
||||
export const REJECT = {
|
||||
head: 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found',
|
||||
at: 'at Planner/LoginView.swift:11 Button("Create account") { Task { try await AccountApi.signUp() } }',
|
||||
};
|
||||
export const REJECT_GROW = [0.6, 1.12];
|
||||
|
||||
// ---------- 4 store: listing text in every language, screenshots in every size ----------
|
||||
/** examples/demo-game/store/shots/copy.json, the hero title per language. */
|
||||
export const COPY_FILE = 'store/shots/copy.json';
|
||||
export const COPY = [
|
||||
{ lang: 'en', title: ['Catch every', 'color drop!'] },
|
||||
{ lang: 'tr', title: ['Renkli damlaları', 'yakala!'] },
|
||||
{ lang: 'ja', title: ['カラフルなしずくを', 'キャッチ!'] },
|
||||
{ lang: 'ar', title: ['التقط كل', 'قطرة ملوّنة!'], rtl: true },
|
||||
// ---------- 4 ask: the request to the agent ----------
|
||||
export const PROMPT = 'Get this game ready for review on both stores.';
|
||||
export const PROMPT_AT = 0.7, PROMPT_CPS = 24;
|
||||
/** Enter: the request is sent (a soft accent), seconds into the scene. */
|
||||
export const PROMPT_SENT = 3.05;
|
||||
|
||||
// ---------- 5 montage ----------
|
||||
/** builds: real lines of `apple build --dry` and `play build --dry` (clock prefix and "(0s)" left out). */
|
||||
export const BUILD_LINES = [
|
||||
{ at: 0.1, text: '$ storebound apple build --dry --build-number 52 --version 1.0.0', cmd: true },
|
||||
{ at: 0.45, text: '[build] capacitor: ios/App/App.xcodeproj scheme App (Release), build 52, version 1.0.0, signing automatic' },
|
||||
{ at: 0.75, text: '[build] build planned (dry run)' },
|
||||
{ at: 1.25, text: '$ storebound play build --dry --version-code 52', cmd: true },
|
||||
{ at: 1.6, text: '[build] capacitor: android :app:bundleRelease, versionCode 52' },
|
||||
{ at: 1.85, text: "[build] signing: the project's own signingConfig" },
|
||||
{ at: 2.1, text: '[build] would run (in android): ./gradlew :app:bundleRelease --no-daemon --console=plain -Pandroid.injected.version.code=52 -PversionCode=52' },
|
||||
{ at: 2.4, text: '[build] build planned (dry run)' },
|
||||
];
|
||||
export const COPY_AT = 0.3, COPY_STEP = 0.3;
|
||||
/** The demo game's renders, one column per store size (cli/shots/lib.ts SIZES), one row per language. */
|
||||
/** shots: the demo game's renders, one column per store size (cli/shots/lib.ts SIZES), one row per language. */
|
||||
export const SHOT_SIZES = [
|
||||
{ id: 'ios69', label: 'iPhone\n6.9″' },
|
||||
{ id: 'ios65', label: 'iPhone\n6.5″' },
|
||||
{ id: 'ipad13', label: 'iPad\n13″' },
|
||||
{ id: 'play', label: 'Play\nphone' },
|
||||
{ id: 'tab7', label: 'Play\n7″ tab' },
|
||||
{ id: 'tab10', label: 'Play\n10″ tab' },
|
||||
{ id: 'feature', label: 'Play\nfeature graphic' },
|
||||
{ id: 'ios69', label: 'iPhone 6.9″' }, { id: 'ios65', label: 'iPhone 6.5″' }, { id: 'ipad13', label: 'iPad 13″' },
|
||||
{ id: 'play', label: 'Play phone' }, { id: 'tab7', label: 'Play 7″' }, { id: 'tab10', label: 'Play 10″' }, { id: 'feature', label: 'Feature graphic' },
|
||||
];
|
||||
export const SHOT_LANGS = COPY.map(c => c.lang);
|
||||
export const SHOTS_AT = 0.6, SHOTS_STEP = 0.25;
|
||||
export const SHOTS_CMD = 'storebound shots render';
|
||||
export const SHOTS_PILL = `*${SHOT_SIZES.length}* sizes × *${COPY.length}* languages`;
|
||||
|
||||
// ---------- 5 products: `play products --dry`, then `catalog diff` ----------
|
||||
/** Real output (clock prefix and the "(0s)" timer left out). at = seconds into the scene. */
|
||||
export const SHOT_LANGS = ['en', 'tr', 'ja', 'ar'];
|
||||
export const SHOTS_AT = 0.1, SHOTS_STEP = 0.2;
|
||||
/** products: real output (clock prefix and the "(0s)" timer left out). */
|
||||
export const PRODUCT_LINES = [
|
||||
{ at: 0.3, text: '$ storebound play products --dry', cmd: true },
|
||||
{ at: 0.6, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
|
||||
{ at: 0.8, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
|
||||
{ at: 1.0, text: '[play] products checked (dry run, nothing written)' },
|
||||
{ at: 1.35, text: '$ storebound catalog diff', cmd: true },
|
||||
{ at: 1.65, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
|
||||
{ at: 1.85, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
|
||||
{ at: 2.05, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
|
||||
{ at: 2.25, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
|
||||
{ at: 2.5, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
|
||||
{ at: 0.05, text: '$ storebound play products --dry', cmd: true },
|
||||
{ at: 0.3, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
|
||||
{ at: 0.48, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
|
||||
{ at: 0.8, text: '$ storebound catalog diff', cmd: true },
|
||||
{ at: 1.05, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
|
||||
{ at: 1.2, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
|
||||
{ at: 1.35, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
|
||||
{ at: 1.5, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
|
||||
{ at: 1.75, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
|
||||
];
|
||||
export const PRODUCTS_PILL = { at: 2.9, text: '*175* App Store regions · *174* Google Play regions · parity *✓*' };
|
||||
|
||||
// ---------- 6 forms: the declarations proposal from the scan, then the Data safety answers ----------
|
||||
/** Real lines of the proposal (scan of the teaser fixture). */
|
||||
/** forms: the declarations proposal (scan of the teaser fixture), then the Data safety rows it fills. */
|
||||
export const DECL_LINES = [
|
||||
{ what: 'PSL_DEVICE_ID', value: 'collect: advertising, analytics, fraud', where: 'Podfile.lock:2' },
|
||||
{ what: 'PSL_APPROX_LOCATION', value: 'collect: advertising, analytics, fraud', where: 'Podfile.lock:2' },
|
||||
|
|
@ -184,150 +223,103 @@ export const DECL_LINES = [
|
|||
{ what: 'PSL_USER_ACCOUNT', value: 'collect: account, functionality', where: 'Podfile.lock:3' },
|
||||
];
|
||||
export const DECL_ASK = 'ask owner encryptedInTransit: ? · all traffic over HTTPS / TLS?';
|
||||
export const DECL_AT = 0.4, DECL_STEP = 0.25, ASK_AT = 1.5;
|
||||
/** Play Data safety form rows the detected answers fill (Play's own labels for the PSL_* types above and the rest). */
|
||||
export const DECL_AT = 0.1, DECL_STEP = 0.18, ASK_AT = 0.9;
|
||||
/** Play Data safety rows the detected answers fill (Play's labels for the PSL_* types above and the rest). */
|
||||
export const DATA_SAFETY_ROWS = ['Device or other IDs', 'Approximate location', 'App interactions', 'Diagnostics', 'Name', 'Email address', 'User IDs'];
|
||||
export const FORM_AT = 1.9, FORM_STEP = 0.14;
|
||||
/** Data safety CSV rows `storebound check` builds from the config. */
|
||||
export const DECL_TOTAL = 768;
|
||||
export const FORMS_PILL = { at: 3.0, text: 'Data safety answers' };
|
||||
|
||||
// ---------- 7 consoles: the console plan of a form without an API, then the app's plan panel ----------
|
||||
export const FORM_AT = 0.5, FORM_STEP = 0.16, FORMS_COUNT_AT = 1.6;
|
||||
/** consoles: the plan in the terminal, then the app's plan panel (DESK.consoles). */
|
||||
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 },
|
||||
{ at: 0.12, text: 'App Store Connect: App Privacy', head: true },
|
||||
{ at: 0.22, text: 'Steps:', head: true },
|
||||
{ at: 0.3, text: ' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar).' },
|
||||
{ at: 0.38, text: ' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]' },
|
||||
{ at: 0.5, text: 'Answer sheet:', head: true },
|
||||
{ at: 0.58, 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: 0.7, text: 'Never press: "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"' },
|
||||
{ at: 0.82, 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];
|
||||
/** The terminal gives way to the app's plan panel over this window, seconds into the scene. */
|
||||
export const CONSOLES_SWAP = [1.05, 0.35];
|
||||
|
||||
// ---------- 8 rejections: three findings with a plain reason each, then fixed ----------
|
||||
// ---------- 6 scan: every finding turns green, the re-scan is clean ----------
|
||||
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. */
|
||||
/** The five findings' own header lines (after the number). */
|
||||
export const FINDINGS = [
|
||||
{ at: 0.8, sev: 'BLOCKER', guideline: '2.1(a)', title: 'Sign-in is required but there is no demo account for App Review', where: 'storebound.config.ts:12',
|
||||
why: 'Apple rejects sign-in apps without a demo account.' },
|
||||
{ at: 1.3, sev: 'BLOCKER', guideline: '5.1.1(v)', title: 'Accounts can be created but no in-app account deletion was found', where: 'Planner/LoginView.swift:11',
|
||||
why: 'Apps that create accounts must let people delete them.' },
|
||||
{ at: 1.8, sev: 'HIGH', guideline: '5.1.2(i)', title: 'Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)', where: 'Planner/Ads.swift:7',
|
||||
why: 'Ads that track people must ask permission first.' },
|
||||
'BLOCKER 2.1(a) · App Store · Sign-in is required but there is no demo account for App Review',
|
||||
'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found',
|
||||
'HIGH 4.8 · App Store · Third-party login (Google / Facebook / ...) without Sign in with Apple or another equivalent login option',
|
||||
'HIGH 3.1.1 · App Store · Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
|
||||
'HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)',
|
||||
];
|
||||
/** Each finding turns green (fixed), then the re-scan of the fixed copy (tests/fixtures/scan/teaser-fixed). */
|
||||
export const FIX_AT = [2.7, 2.95, 3.2];
|
||||
export const RESCAN_AT = 3.6;
|
||||
export const FINDINGS_AT = 0.4, FINDINGS_STEP = 0.12;
|
||||
/** Each finding turns green (fixed) on a beat, then the re-scan of the fixed copy (tests/fixtures/scan/teaser-fixed). */
|
||||
export const FIX_AT = [1.0, 1.5, 2.0, 2.5, 3.0];
|
||||
export const RESCAN_AT = 3.45;
|
||||
export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low';
|
||||
|
||||
// ---------- 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: 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). */
|
||||
// ---------- desktop: real frames of the app in fixture mode (desktop.ts) ----------
|
||||
/** One entry per scene. groups: what the window shows, in order (one captured frame each); 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. rings: a quiet ring on a captured box (boxes.json), no text. pointer: glides onto a box and rests there;
|
||||
* click = the moment it presses (only the owner's button, in the waiting scene). */
|
||||
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 }],
|
||||
cam: { from: { f: [0.5, 0.56], z: 1.12 }, to: { f: [0.5, 0.58], z: 1.22 } },
|
||||
rings: [{ at: 1.6, until: 3.2, target: 'cpFinal', 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 } },
|
||||
callouts: [{ at: 1.1, until: 5, target: 'prismRow', text: 'Both stores per app: rollout, crash rate' }],
|
||||
// Releases > Send for review: build 52 picked, the one button left; the camera settles on it, the pointer rests
|
||||
button: {
|
||||
groups: [{ frame: 'submit-ready' }],
|
||||
cam: { from: { f: [0.45, 0.5], z: 1.0 }, to: { f: [0.3, 0.68], z: 1.55 } },
|
||||
rings: [{ at: 3.2, until: 6.3, target: 'submitBtn' }],
|
||||
pointer: { at: 1.9, d: 1.1, target: 'submitBtn' },
|
||||
},
|
||||
charts: {
|
||||
groups: [{ frame: 'growth', sweep: { seq: 'growth', at: 0.55, d: 1.75 } }, { frame: 'health', sweep: { seq: 'health', at: 2.85, d: 1.35 } }],
|
||||
swap: [2.35, 2.75],
|
||||
cam: { from: { f: [0.55, 0.5], z: 1.0 }, to: { f: [0.55, 0.6], z: 1.1 } },
|
||||
callouts: [{ at: 0.7, until: 2.4, target: 'growthPlot', text: "Every day, the stores' own numbers" }],
|
||||
},
|
||||
catalog: {
|
||||
groups: [{ frame: 'prices' }],
|
||||
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' }],
|
||||
},
|
||||
// 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.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 },
|
||||
// the press, then the app's own "Waiting for review" state
|
||||
waiting: {
|
||||
groups: [{ frame: 'submit-ready' }, { frame: 'submit-waiting' }],
|
||||
swap: [0.45, 0.9],
|
||||
cam: { from: { f: [0.3, 0.68], z: 1.55 }, to: { f: [0.4, 0.4], z: 1.3 } },
|
||||
rings: [{ at: 1.2, until: 5.3, target: 'waitingItem', green: true }],
|
||||
pointer: { at: -1, d: 0.01, target: 'submitBtn', click: 0.2, until: 0.6 },
|
||||
},
|
||||
};
|
||||
export const DESK_SCENES = Object.keys(DESK);
|
||||
/** Every captured frame a desktop scene names (sweep frames are listed in boxes.json by desktop.ts). */
|
||||
/** Every captured frame a desktop scene names. */
|
||||
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));
|
||||
/** The click on Submit (absolute seconds): the music's resolve starts here. */
|
||||
export const CLICK_T = scene('waiting').start + DESK.waiting.pointer.click;
|
||||
|
||||
// ---------- 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';
|
||||
// ---------- 9 end card ----------
|
||||
/** Seconds into the end scene at which each line comes in. */
|
||||
export const END = { markAt: 0.25, wordAt: 0.5, lineAt: 0.9, urlAt: 1.25, d: 0.6 };
|
||||
export const END_LINE = 'From an empty folder to Submit.';
|
||||
export const END_URL = 'Open source · storebound.dev';
|
||||
|
||||
// ---------- sound cues (music.js) ----------
|
||||
/** Soft accents: one on every scene change. */
|
||||
export const CUTS = SCENES.slice(1).map(s => ({ t: s.start, id: s.id }));
|
||||
/** Gentle ticks: list items, files, plan lines, products, form rows, findings (low), fixes (bright). */
|
||||
/** Cuts with a sound accent (every scene change but the seamless button -> waiting one). */
|
||||
export const CUTS = SCENES.slice(1).filter(s => s.id !== 'waiting').map(s => ({ t: s.start, id: s.id }));
|
||||
/** Ticks: cards landing (thud), lines and rows (tiny), the finding (low), fixes (bright, rising), the send (soft). */
|
||||
export function ticks() {
|
||||
const at = id => scene(id).start;
|
||||
return [
|
||||
...TODO.map(x => ({ t: at('hook') + x.at, kind: 'soft' })),
|
||||
{ t: at('hook') + MAIL.at, kind: 'mail' },
|
||||
...INIT_LINES.map((_, i) => ({ t: at('init') + INIT_OUT_AT + 0.2 + i * INIT_STEP, kind: 'tiny' })),
|
||||
...PLAN_LINES.map((_, i) => ({ t: at('plan') + PLAN_AT + i * PLAN_STEP, kind: 'tiny' })),
|
||||
...COPY.map((_, i) => ({ t: at('store') + COPY_AT + i * COPY_STEP, kind: 'soft' })),
|
||||
...PILE.map((c, i) => ({ t: at('pile') + c.at, kind: 'thud', i })),
|
||||
{ t: at('reject') + 0.05, kind: 'low' },
|
||||
{ t: at('ask') + PROMPT_SENT, kind: 'send' },
|
||||
...BUILD_LINES.filter(l => !l.cmd).map(l => ({ t: at('builds') + l.at, kind: 'tiny' })),
|
||||
...PRODUCT_LINES.filter(l => !l.cmd).map(l => ({ t: at('products') + l.at, kind: 'tiny' })),
|
||||
{ t: at('products') + PRODUCTS_PILL.at, kind: 'good', step: 2 },
|
||||
...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 => (id === 'submit' && c.green ? { t: at(id) + c.at, kind: 'good', step: 2 } : { t: at(id) + c.at, kind: 'soft' }))),
|
||||
...CONSOLE_LINES.map(l => ({ t: at('consoles') + l.at, kind: 'tiny' })),
|
||||
...FIX_AT.map((t, i) => ({ t: at('scan') + t, kind: 'good', step: i })),
|
||||
{ t: at('scan') + RESCAN_AT, kind: 'clean' },
|
||||
];
|
||||
}
|
||||
/** Seconds at which each typed character lands (soft key clicks). */
|
||||
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);
|
||||
for (let i = 0; i < PROMPT.length; i++) if (PROMPT[i] !== ' ' || i % 2) out.push(scene('ask').start + PROMPT_AT + i / PROMPT_CPS);
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
// 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, 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,
|
||||
RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_CMD, SHOTS_PILL, SHOTS_STEP,
|
||||
SUBMIT, TAGLINE, TODO, URL, scene } from './script.js';
|
||||
// Teaser picture (v8, a story): 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 { ASK_AT, BUILD_LINES, CAMERA, CAP_FADE, CAPTIONS, captionTimes, CONSOLE_CMD, CONSOLE_LINES, CONSOLES_SWAP,
|
||||
DATA_SAFETY_CSV, DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESK, DESK_SCENES, DURATION, END, END_LINE, END_URL,
|
||||
FINDINGS, FINDINGS_AT, FINDINGS_STEP, FIX_AT, FORM_AT, FORM_STEP, FORMAT, FORMS_COUNT_AT, FPS, GAME_AT, GAME_SCREENS, GAME_STEP, MONTAGE,
|
||||
PILE, PILE_TITLES, PRICE_ROWS, PRIVACY_ANSWERS, PRIVACY_STEPS, PRODUCT_LINES, PROMPT, PROMPT_AT, PROMPT_CPS, PROMPT_SENT,
|
||||
REJECT, REJECT_GROW, RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_STEP, scene, veil } from './script.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const W = FORMAT.w, H = FORMAT.h;
|
||||
|
|
@ -19,174 +17,222 @@ const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
|
|||
const prog = (t, a, d) => (d <= 0 ? (t >= a ? 1 : 0) : clamp((t - a) / d));
|
||||
const eoc = p => 1 - Math.pow(1 - p, 3);
|
||||
const eio = p => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2);
|
||||
/** The camera: a quick ease-out (quartic) that settles at 80% of the scene, so each move reads snappy, then rests. */
|
||||
/** A quick ease-out (quartic) that settles at 80% of the scene: the montage's push. */
|
||||
const ecam = p => 1 - Math.pow(1 - clamp(p / 0.8), 4);
|
||||
/** A slow sine ease over the whole scene: the story scenes' drift. */
|
||||
const esin = p => 0.5 - Math.cos(Math.PI * clamp(p)) / 2;
|
||||
const lerp = (a, b, p) => a + (b - a) * p;
|
||||
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const set = (el, k, v) => { if (el.__s?.[k] !== v) { (el.__s ??= {})[k] = v; el.style[k] = v; } };
|
||||
const html = (el, v) => { if (el.__h !== v) { el.__h = v; el.innerHTML = v; } };
|
||||
/** *word* -> <b>word</b> (pills) */
|
||||
const bold = s => esc(s).replace(/\*([^*]+)\*/g, '<b>$1</b>');
|
||||
const hl = s => esc(s).replace(/\*([^*]+)\*/g, '<span class="hl">$1</span>');
|
||||
/** A gentle appear: opacity and a small rise. */
|
||||
function appear(el, t, at, d = 0.35, rise = 14) {
|
||||
function appear(el, t, at, d = 0.3, rise = 10) {
|
||||
const p = prog(t, at, d);
|
||||
set(el, 'opacity', String(eoc(p)));
|
||||
set(el, 'transform', `translateY(${((1 - eoc(p)) * rise).toFixed(1)}px)`);
|
||||
return p;
|
||||
}
|
||||
function pillAt(el, t, at, base = 'translate(-50%, 0)') {
|
||||
const p = prog(t, at, 0.4);
|
||||
set(el, 'opacity', String(eoc(p)));
|
||||
set(el, 'transform', `${base} translateY(${((1 - eoc(p)) * 16).toFixed(1)}px)`);
|
||||
}
|
||||
const tagged = l => esc(l).replace(/^(\[\w+\])/, '<span class="tag">$1</span>');
|
||||
|
||||
// ---------- scenes: dissolves and one camera move each ----------
|
||||
// ---------- scenes: transitions centred on the cuts, one camera move each ----------
|
||||
const sceneEls = Object.fromEntries(SCENES.map(s => [s.id, { el: $(`#s-${s.id}`), cam: $(`#s-${s.id} > .cam`) }]));
|
||||
function scenes(t) {
|
||||
SCENES.forEach((s, i) => {
|
||||
const { el, cam } = sceneEls[s.id];
|
||||
const from = i === 0 ? -1 : s.start - DISSOLVE / 2, until = i === SCENES.length - 1 ? Infinity : s.end + DISSOLVE / 2;
|
||||
const next = SCENES[i + 1];
|
||||
const from = i === 0 ? -1 : s.start - s.x / 2, until = next ? next.start + next.x / 2 : Infinity;
|
||||
if (t < from || t >= until) { set(el, 'visibility', 'hidden'); set(el, 'opacity', '0'); return; }
|
||||
set(el, 'visibility', 'visible');
|
||||
set(el, 'opacity', String(i === 0 ? 1 : eio(prog(t, from, DISSOLVE))));
|
||||
// the first scene comes up from black; the others dissolve in over the previous one
|
||||
set(el, 'opacity', String(i === 0 ? eio(prog(t, 0, 0.9)) : eio(prog(t, from, s.x))));
|
||||
const c = CAMERA[s.id];
|
||||
const p = ecam(clamp((t - (s.start - DISSOLVE / 2)) / (s.end - s.start + DISSOLVE)));
|
||||
const raw = (t - s.start) / (s.end - s.start);
|
||||
const p = MONTAGE.includes(s.id) ? ecam(raw) : esin(raw);
|
||||
const z = lerp(c.from.z, c.to.z, p), x = lerp(c.from.x, c.to.x, p), y = lerp(c.from.y, c.to.y, p);
|
||||
set(cam, 'transform', `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${z.toFixed(5)})`);
|
||||
});
|
||||
}
|
||||
const active = t => SCENES.find(s => t >= s.start && t < s.end) ?? SCENES[SCENES.length - 1];
|
||||
const active = t => SCENES.find(s => t >= s.start && t < s.end) ?? SCENES.at(-1);
|
||||
|
||||
// ---------- background: a slow drift ----------
|
||||
const g1 = $('#bg .g1'), g2 = $('#bg .g2'), grid = $('#bg .grid');
|
||||
// ---------- background: a slow drift, the veil ----------
|
||||
const g1 = $('#bg .g1'), g2 = $('#bg .g2'), grid = $('#bg .grid'), veilEl = $('#veil');
|
||||
function background(t) {
|
||||
set(g1, 'transform', `translate(${(W * 0.25 + Math.sin(t * 0.11) * 160 - 700).toFixed(1)}px, ${(H * 0.35 + Math.cos(t * 0.09) * 120 - 700).toFixed(1)}px)`);
|
||||
set(g2, 'transform', `translate(${(W * 0.8 + Math.cos(t * 0.1) * 160 - 550).toFixed(1)}px, ${(H * 0.75 + Math.sin(t * 0.12) * 110 - 550).toFixed(1)}px)`);
|
||||
set(grid, 'transform', `translate(${((t * 6) % 96).toFixed(2)}px, ${((t * 8) % 96).toFixed(2)}px)`);
|
||||
set(veilEl, 'opacity', veil(t).toFixed(4));
|
||||
}
|
||||
|
||||
// ---------- caption ----------
|
||||
const capEl = $('#cap');
|
||||
// ---------- captions: the story line (top) and the montage word (edge) ----------
|
||||
const capEl = $('#cap'), capBand = $('#capband'), edgeEl = $('#edge'), edgeBand = $('#edgeband');
|
||||
const CT = Object.fromEntries(captionTimes().map(c => [c.id, c]));
|
||||
let capId = null;
|
||||
function buildCaption(c) {
|
||||
capEl.className = c.hl ?? '';
|
||||
capEl.style.fontSize = '';
|
||||
capEl.innerHTML = c.lines.map(l => `<span class="line">${esc(l).replace(/\*([^*]+)\*/g, '<span class="hl">$1</span>')}</span>`).join('');
|
||||
const avail = capEl.clientWidth, base = parseFloat(getComputedStyle(capEl).fontSize);
|
||||
const widest = Math.max(...[...capEl.querySelectorAll('.line')].map(l => l.scrollWidth));
|
||||
if (widest > avail) capEl.style.fontSize = `${Math.floor(base * avail / widest)}px`;
|
||||
}
|
||||
function captions(t) {
|
||||
const s = active(t), c = CAPTIONS.find(x => x.id === s.id), ct = CT[s.id];
|
||||
if (!c) { set(capEl, 'opacity', '0'); return; }
|
||||
if (capId !== c.id) { capId = c.id; buildCaption(c); }
|
||||
const pin = s === SCENES[0] && t < CAP_DELAY ? 0 : eoc(prog(t, ct.at, CAP_IN));
|
||||
const pout = s === SCENES.at(-1) ? 0 : prog(t, ct.to, CAP_OUT);
|
||||
set(capEl, 'opacity', String(pin * (1 - pout)));
|
||||
set(capEl, 'transform', `translateY(${((1 - pin) * 12 - pout * 8).toFixed(1)}px)`);
|
||||
}
|
||||
// keep the constants in use (they define captionTimes, imported above for the timing test)
|
||||
void CAP_DELAY; void CAP_END;
|
||||
|
||||
// ---------- 1 hook ----------
|
||||
const todoRows = $('#todo .rows'), mailEl = $('#mail'), appcard = $('#appcard');
|
||||
$('#appcard .nm').textContent = APP_NAME;
|
||||
todoRows.innerHTML = TODO.map(x => `<div class="row"><span class="box"></span>${esc(x.text)}</div>`).join('');
|
||||
const todoEls = [...todoRows.querySelectorAll('.row')];
|
||||
mailEl.querySelector('.from').textContent = MAIL.from;
|
||||
mailEl.querySelector('.date').textContent = MAIL.date;
|
||||
mailEl.querySelector('.subj').textContent = MAIL.subject;
|
||||
mailEl.querySelector('.prev').innerHTML = `Hello, Thank you for submitting your app… <b>${esc(MAIL.guideline)}</b>`;
|
||||
function hook(t) {
|
||||
const s = scene('hook'); if (t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear(appcard, r, 0.05, 0.4, 20);
|
||||
appear($('#todo'), r, 0.2, 0.4, 20);
|
||||
todoEls.forEach((el, i) => appear(el, r, TODO[i].at, 0.4, 10));
|
||||
const p = prog(r, MAIL.at, MAIL_IN);
|
||||
set(mailEl, 'opacity', String(eoc(p)));
|
||||
set(mailEl, 'transform', `translateY(${((1 - eoc(p)) * 50).toFixed(1)}px)`);
|
||||
const s = active(t), c = CAPTIONS.find(x => x.id === s.id);
|
||||
let top = 0, edge = 0, rise = 0;
|
||||
if (c) {
|
||||
const ct = CT[c.id], [fin, fout] = CAP_FADE[c.pos];
|
||||
const pin = eoc(prog(t, ct.at, fin)), pout = prog(t, ct.to, fout);
|
||||
const op = pin * (1 - pout);
|
||||
rise = (1 - pin) * 14 - pout * 8;
|
||||
if (capId !== c.id) {
|
||||
capId = c.id;
|
||||
if (c.pos === 'top') { capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); } else edgeEl.innerHTML = hl(c.text);
|
||||
}
|
||||
if (c.pos === 'top') top = op; else edge = op;
|
||||
}
|
||||
set(capEl, 'opacity', String(top)); set(capBand, 'opacity', String(top));
|
||||
set(capEl, 'transform', `translateY(${rise.toFixed(1)}px)`);
|
||||
set(edgeEl, 'opacity', String(edge)); set(edgeBand, 'opacity', String(Math.min(1, edge * 1.2)));
|
||||
set(edgeEl, 'transform', `translateX(${(edge < 1 ? (1 - edge) * -18 : 0).toFixed(1)}px)`);
|
||||
}
|
||||
|
||||
// ---------- 2 init ----------
|
||||
const folderEl = $('#folder'), filesEl = $('#folder .files'), emptyEl = $('#folder .empty'), initBody = $('#initterm .body');
|
||||
$('#folder .fname').textContent = `${FOLDER}/`;
|
||||
/** The folder list: one entry per file `init` creates (from the real output lines). */
|
||||
const FILES = INIT_LINES.map((l, i) => ({ i, m: l.match(/^\[init\] create (.+)$/) })).filter(x => x.m).map(x => ({ i: x.i, path: x.m[1] }));
|
||||
filesEl.innerHTML = FILES.map(f => {
|
||||
const k = f.path.lastIndexOf('/');
|
||||
return `<div class="f"><span class="ic"></span><span>${k >= 0 ? `<span class="d">${esc(f.path.slice(0, k + 1))}</span>` : ''}${esc(f.path.slice(k + 1))}</span></div>`;
|
||||
}).join('');
|
||||
const fileEls = [...filesEl.querySelectorAll('.f')];
|
||||
const tagged = l => esc(l).replace(/^(\[\w+\])/, '<span class="tag">$1</span>');
|
||||
initBody.innerHTML = `<span class="ln" data-k="p"></span>${INIT_LINES.map(l => `<span class="ln">${tagged(l).replace(/ (create) /, ' <span class="okk">$1</span> ')}</span>`).join('')}`;
|
||||
const initLns = [...initBody.querySelectorAll('.ln')];
|
||||
function init(t) {
|
||||
const s = scene('init'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
// ---------- 1 game: the game's own screens ----------
|
||||
const gameEl = $('#game');
|
||||
const phones = GAME_SCREENS.map((id, i) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'phone';
|
||||
Object.assign(el.style, { left: `${195 + i * 400}px`, backgroundImage: `url(out/assets/game/${id}.jpg)` });
|
||||
gameEl.appendChild(el);
|
||||
return el;
|
||||
});
|
||||
function game(t) {
|
||||
const s = scene('game'); if (t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
const n = clamp(Math.floor((r - INIT_TYPE_AT) * INIT_CPS), 0, INIT_CMD.length);
|
||||
const caret = r < INIT_OUT_AT && (n < INIT_CMD.length || Math.floor(r * 2.2) % 2 === 0);
|
||||
html(initLns[0], `<span class="tag">~/${esc(FOLDER)}</span> <span class="ps">$</span> <span class="cmd">${esc(INIT_CMD.slice(0, n))}</span>${caret ? '<span class="caret"></span>' : ''}`);
|
||||
set(initLns[0], 'opacity', '1');
|
||||
initLns.slice(1).forEach((el, i) => appear(el, r, INIT_OUT_AT + 0.2 + i * INIT_STEP, 0.25, 6));
|
||||
fileEls.forEach((el, k) => {
|
||||
const p = prog(r, INIT_OUT_AT + 0.2 + FILES[k].i * INIT_STEP, 0.3);
|
||||
phones.forEach((el, i) => {
|
||||
const p = prog(r, GAME_AT + i * GAME_STEP, 0.9);
|
||||
set(el, 'opacity', String(eoc(p)));
|
||||
set(el, 'transform', `translateX(${((1 - eoc(p)) * -16).toFixed(1)}px)`);
|
||||
// a slow float, each phone on its own phase
|
||||
const fy = Math.sin(r * 0.9 + i * 1.3) * 6;
|
||||
set(el, 'transform', `translateY(${((1 - eoc(p)) * 40 + fy).toFixed(1)}px) rotate(${((i - 1.5) * 1.2).toFixed(2)}deg)`);
|
||||
});
|
||||
set(emptyEl, 'opacity', String(1 - prog(r, INIT_OUT_AT + 0.1, 0.3)));
|
||||
void folderEl;
|
||||
}
|
||||
|
||||
// ---------- 3 plan ----------
|
||||
const promptEl = $('#prompt'), promptTxt = $('#prompt .txt'), planTerm = $('#planterm'), planBody = $('#planterm .body'), planPill = $('#plan-pill');
|
||||
const planLine = l => {
|
||||
if (/^\S/.test(l)) return `<span class="num">${esc(l)}</span>`;
|
||||
if (/^ (both stores|App Store|Google Play)/.test(l)) return `<span class="num">${esc(l)}</span>`;
|
||||
return esc(l).replace(/^(\s+\d+\.) (CONFIG |STORE |BLOCKER)/, (m, a, b) => `<span class="tag">${a}</span> <span class="${b.startsWith('BLOCKER') ? 'bad' : b.startsWith('STORE') ? 'warnk' : 'okk'}">${b}</span>`);
|
||||
};
|
||||
planBody.innerHTML = `<span class="ln" data-k="c"></span>${PLAN_LINES.map(l => `<span class="ln">${planLine(l)}</span>`).join('')}`;
|
||||
const planLns = [...planBody.querySelectorAll('.ln')];
|
||||
planPill.innerHTML = bold(PLAN_PILL);
|
||||
function plan(t) {
|
||||
const s = scene('plan'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear(promptEl, r, 0.05, 0.35, 10);
|
||||
const n = clamp(Math.floor((r - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length);
|
||||
html(promptTxt, `${esc(PROMPT.slice(0, n))}${n < PROMPT.length || r < PLAN_CMD_AT ? '<span class="caret"></span>' : ''}`);
|
||||
appear(planTerm, r, PLAN_CMD_AT - 0.35, 0.4, 16);
|
||||
html(planLns[0], `<span class="ps">$</span> <span class="cmd">${esc(PLAN_CMD)}</span>`);
|
||||
appear(planLns[0], r, PLAN_CMD_AT, 0.25, 4);
|
||||
planLns.slice(1).forEach((el, i) => appear(el, r, PLAN_AT + i * PLAN_STEP, 0.25, 6));
|
||||
pillAt(planPill, r, PLAN_AT + PLAN_LINES.length * PLAN_STEP + 0.15);
|
||||
}
|
||||
|
||||
// ---------- 4 store ----------
|
||||
const copyRows = $('#copy .rows'), shotsEl = $('#shots'), storePill = $('#store-pill'), shotCmd = $('#shotcmd');
|
||||
$('#copy .fname').textContent = COPY_FILE;
|
||||
copyRows.innerHTML = COPY.map(c => `<div class="row"><span class="lg">${esc(c.lang)}</span><span class="tt${c.rtl ? ' rtl' : ''}">${esc(c.title.join(' '))}</span></div>`).join('');
|
||||
const copyEls = [...copyRows.querySelectorAll('.row')];
|
||||
storePill.innerHTML = bold(SHOTS_PILL);
|
||||
shotCmd.querySelector('.cmd').textContent = SHOTS_CMD;
|
||||
/** The grid: one column per size (portrait sizes by aspect, the feature graphic last), one row per language. */
|
||||
// ---------- 2 pile: the store work lands, card after card ----------
|
||||
const pileEl = $('#pile');
|
||||
const DIMS = { ios69: [1320, 2868], ios65: [1284, 2778], ipad13: [2064, 2752], play: [1080, 1920], tab7: [1080, 1920], tab10: [1440, 2560], feature: [1024, 500] };
|
||||
const ROW_H = 33.6, BODY_H = 330;
|
||||
const rowsOf = c => {
|
||||
const from = c.from ?? 0;
|
||||
if (c.kind === 'privacy') return PRIVACY_ANSWERS.slice(from).map(l => esc(l).replace(/\[ok\]/, '<span class="okk">[ok]</span>'));
|
||||
if (c.kind === 'steps') return PRIVACY_STEPS.map(l => esc(l).replace(/^(\s+\d+\.)/, '<span class="tag">$1</span>').replace(/(\[click: [^\]]+\])/, '<span class="loc">$1</span>'));
|
||||
if (c.kind === 'csv') return DATA_SAFETY_CSV.slice(from).map(l => esc(l).replace(/^(PSL_[A-Z_]+)/, '<span class="num">$1</span>').replace(/,TRUE,/, ',<span class="okk">TRUE</span>,'));
|
||||
if (c.kind === 'prices') return PRICE_ROWS.slice(from).map(l => esc(l).replace(/,([A-Z]{2}),([A-Z]{3}),([\d.]+),/, ',<span class="num">$1</span>,$2,<span class="loc">$3</span>,'));
|
||||
return [];
|
||||
};
|
||||
const pileCards = PILE.map(c => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card pcard';
|
||||
const h = 64 + BODY_H;
|
||||
Object.assign(el.style, { left: `${c.x - c.w / 2}px`, top: `${c.y - h / 2}px`, width: `${c.w}px` });
|
||||
let body;
|
||||
if (c.kind === 'shots') {
|
||||
// two languages of the demo game's renders, every store size, side by side
|
||||
const langs = [c.lang ?? 'en', SHOT_LANGS[(SHOT_LANGS.indexOf(c.lang ?? 'en') + 1) % SHOT_LANGS.length]];
|
||||
const GAP = 10, RH = (BODY_H - 32 - GAP) / 2;
|
||||
const ws = SHOT_SIZES.map(sz => RH * DIMS[sz.id][0] / DIMS[sz.id][1]);
|
||||
const total = ws.reduce((a, b) => a + b, 0) + GAP * (ws.length - 1);
|
||||
let tiles = '';
|
||||
langs.forEach((lang, j) => {
|
||||
let x = (c.w - 44 - total) / 2;
|
||||
SHOT_SIZES.forEach((sz, i) => {
|
||||
tiles += `<div class="tile" style="left:${x.toFixed(1)}px;top:${(j * (RH + GAP)).toFixed(1)}px;width:${ws[i].toFixed(1)}px;height:${RH.toFixed(1)}px;background-image:url(out/assets/shots/${lang}-${sz.id}.jpg)"></div>`;
|
||||
x += ws[i] + GAP;
|
||||
});
|
||||
});
|
||||
body = `<div class="grid">${tiles}</div>`;
|
||||
} else body = `<div class="rows">${rowsOf(c).map(r => `<div>${r}</div>`).join('')}</div>`;
|
||||
el.innerHTML = `<div class="ch"><span class="dots"><i></i><i></i><i></i></span>${esc(PILE_TITLES[c.kind])}</div><div class="body">${body}<div class="shade"></div></div>`;
|
||||
pileEl.appendChild(el);
|
||||
const rows = el.querySelector('.rows');
|
||||
const n = rows ? rowsOf(c).length : 0;
|
||||
return { c, el, rows, shade: el.querySelector('.shade'), maxScroll: Math.max(0, n * ROW_H + 28 - BODY_H) };
|
||||
});
|
||||
function pile(t) {
|
||||
const s = scene('pile'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
pileCards.forEach(({ c, el, rows, shade, maxScroll }, i) => {
|
||||
const p = prog(r, c.at, 0.32);
|
||||
set(el, 'opacity', String(clamp(p * 2.5)));
|
||||
const e = eoc(p);
|
||||
set(el, 'transform', `translateY(${lerp(-70, 0, e).toFixed(1)}px) rotate(${lerp(c.rot * 2.2, c.rot, e).toFixed(2)}deg) scale(${lerp(1.22, 1, e).toFixed(4)})`);
|
||||
// the rows keep streaming: there is always more
|
||||
if (rows) set(rows, 'transform', `translateY(${(-Math.min(maxScroll, Math.max(0, r - c.at) * 34)).toFixed(1)}px)`);
|
||||
// older cards sink into the shadow of the new ones
|
||||
const above = PILE.slice(i + 1).reduce((k, x) => k + eoc(prog(r, x.at, 0.32)), 0);
|
||||
set(shade, 'opacity', String(Math.min(0.6, above * 0.16).toFixed(3)));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 3 reject: one finding grows to fill the frame ----------
|
||||
const findEl = $('#finding');
|
||||
{
|
||||
const m = REJECT.head.match(/^(BLOCKER) (\S+) · App Store · (.+)$/);
|
||||
findEl.querySelector('.l1').innerHTML = `<span class="sev">${m[1]}</span><span class="gl">${esc(m[2])}</span><span>· App Store ·</span><span>${esc(m[3])}</span>`;
|
||||
findEl.querySelector('.l2').innerHTML = esc(REJECT.at).replace(/^(at \S+)/, '<span class="loc">$1</span>');
|
||||
}
|
||||
function reject(t) {
|
||||
const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
set(findEl, 'opacity', String(eoc(prog(r, -0.1, 0.45))));
|
||||
const z = lerp(REJECT_GROW[0], REJECT_GROW[1], esin(prog(r, -0.1, s.end - s.start + 0.3)));
|
||||
set(findEl, 'transform', `translate(-50%, -50%) scale(${z.toFixed(4)})`);
|
||||
}
|
||||
|
||||
// ---------- 4 ask: the request to the agent ----------
|
||||
const promptEl = $('#prompt'), promptTxt = $('#prompt .txt'), sentEl = $('#prompt .sent');
|
||||
function ask(t) {
|
||||
const s = scene('ask'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear(promptEl, r, 0.15, 0.6, 16);
|
||||
const n = clamp(Math.floor((r - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length);
|
||||
const caret = r < PROMPT_SENT && (n < PROMPT.length || Math.floor(r * 2.2) % 2 === 0);
|
||||
html(promptTxt, `${esc(PROMPT.slice(0, n))}${caret ? '<span class="caret"></span>' : ''}`);
|
||||
const sp = prog(r, PROMPT_SENT, 0.25);
|
||||
set(sentEl, 'opacity', String(lerp(0.35, 1, eoc(sp))));
|
||||
set(sentEl, 'transform', `scale(${(1 + Math.sin(Math.PI * sp) * 0.18).toFixed(3)})`);
|
||||
}
|
||||
|
||||
// ---------- 5 montage ----------
|
||||
const cmdLine = text => `<span class="ps">$</span> <span class="cmd">${esc(text.replace(/^\$ /, ''))}</span>`;
|
||||
function termLines(sel, lines, fmt) {
|
||||
const body = $(`${sel} .body`);
|
||||
body.innerHTML = lines.map((l, i) => `${i && l.cmd ? '<span class="ln sp" style="opacity:1"> </span>' : ''}<span class="ln">${l.cmd ? cmdLine(l.text) : fmt(l.text)}</span>`).join('');
|
||||
return [...body.querySelectorAll('.ln:not(.sp)')];
|
||||
}
|
||||
const buildLns = termLines('#buildterm', BUILD_LINES, l => tagged(l).replace(/(build 52|versionCode 52)/, '<span class="num">$1</span>').replace(/(build planned \(dry run\))/, '<span class="okk">$1</span>'));
|
||||
const prodLns = termLines('#prodterm', PRODUCT_LINES, l => tagged(l).replace(/^ok {4}/, '<span class="okk">ok</span> ')
|
||||
.replace(/(\d+ regions?|\d+ region prices)/, '<span class="num">$1</span>').replace(/(0 mismatch\(es\))/, '<span class="okk">$1</span>'));
|
||||
function montageTerm(id, sel, lns, lines) {
|
||||
return t => {
|
||||
const s = scene(id); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear($(sel), r, -0.1, 0.2, 10);
|
||||
lns.forEach((el, i) => appear(el, r, lines[i].at, 0.18, 6));
|
||||
};
|
||||
}
|
||||
const builds = montageTerm('builds', '#buildterm', buildLns, BUILD_LINES);
|
||||
const products = montageTerm('products', '#prodterm', prodLns, PRODUCT_LINES);
|
||||
|
||||
// shots: one column per store size, one row per language
|
||||
const shotsEl = $('#shots');
|
||||
const shotTiles = [], shotLabels = [], langLabels = [];
|
||||
{
|
||||
const LAB = 50, LANGW = 52, GAP = 14, AREA_W = 1020, AREA_H = 700;
|
||||
const AREA_W = 1700, AREA_H = 760, LAB = 44, LANGW = 60, GAP = 18;
|
||||
const RH = (AREA_H - LAB - GAP * (SHOT_LANGS.length - 1)) / SHOT_LANGS.length;
|
||||
const FW = 280, FH = FW * DIMS.feature[1] / DIMS.feature[0];
|
||||
const widths = SHOT_SIZES.map(sz => (sz.id === 'feature' ? FW : RH * DIMS[sz.id][0] / DIMS[sz.id][1]));
|
||||
const widths = SHOT_SIZES.map(sz => RH * DIMS[sz.id][0] / DIMS[sz.id][1]);
|
||||
const total = LANGW + widths.reduce((a, b) => a + b, 0) + GAP * SHOT_SIZES.length;
|
||||
let x = (AREA_W - total) / 2 + LANGW + GAP;
|
||||
const x0 = (AREA_W - total) / 2;
|
||||
SHOT_LANGS.forEach((lang, j) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'lang'; el.textContent = lang;
|
||||
Object.assign(el.style, { left: `${(AREA_W - total) / 2}px`, top: `${LAB + j * (RH + GAP) + RH / 2 - 10}px` });
|
||||
Object.assign(el.style, { left: `${x0}px`, top: `${LAB + j * (RH + GAP) + RH / 2 - 11}px` });
|
||||
shotsEl.appendChild(el); langLabels.push(el);
|
||||
});
|
||||
let x = x0 + LANGW + GAP;
|
||||
SHOT_SIZES.forEach((sz, i) => {
|
||||
const w = widths[i], h = sz.id === 'feature' ? FH : RH;
|
||||
const w = widths[i];
|
||||
const lab = document.createElement('div');
|
||||
lab.className = 'lab'; lab.textContent = sz.label;
|
||||
Object.assign(lab.style, { left: `${x + w / 2}px`, top: '0px', transform: 'translateX(-50%)' });
|
||||
|
|
@ -194,269 +240,194 @@ const shotTiles = [], shotLabels = [], langLabels = [];
|
|||
SHOT_LANGS.forEach((lang, j) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'tile';
|
||||
Object.assign(el.style, { left: `${x}px`, top: `${LAB + j * (RH + GAP) + (RH - h) / 2}px`, width: `${w}px`, height: `${h}px`,
|
||||
backgroundImage: `url(out/assets/shots/${lang}-${sz.id}.jpg)`, borderRadius: sz.id === 'feature' ? '6px' : '9px' });
|
||||
Object.assign(el.style, { left: `${x}px`, top: `${LAB + j * (RH + GAP)}px`, width: `${w}px`, height: `${RH}px`,
|
||||
backgroundImage: `url(out/assets/shots/${lang}-${sz.id}.jpg)`, borderRadius: sz.id === 'feature' ? '6px' : '10px' });
|
||||
shotsEl.appendChild(el);
|
||||
shotTiles.push({ el, col: i, row: j });
|
||||
});
|
||||
x += w + GAP;
|
||||
});
|
||||
}
|
||||
function store(t) {
|
||||
const s = scene('store'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
function shots(t) {
|
||||
const s = scene('shots'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear($('#copy'), r, 0.0, 0.4, 14);
|
||||
copyEls.forEach((el, i) => appear(el, r, COPY_AT + i * COPY_STEP, 0.4, 10));
|
||||
langLabels.forEach((el, j) => appear(el, r, SHOTS_AT - 0.2 + j * 0.05, 0.3, 0));
|
||||
shotLabels.forEach(({ el, col }) => { const p = prog(r, SHOTS_AT + col * SHOTS_STEP, 0.35); set(el, 'opacity', String(eoc(p))); });
|
||||
langLabels.forEach(el => appear(el, r, 0, 0.2, 0));
|
||||
shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT + col * SHOTS_STEP, 0.25)))));
|
||||
for (const { el, col, row } of shotTiles) {
|
||||
const p = prog(r, SHOTS_AT + col * SHOTS_STEP + row * 0.06, 0.4);
|
||||
const p = prog(r, SHOTS_AT + col * SHOTS_STEP + row * 0.05, 0.28);
|
||||
set(el, 'opacity', String(eoc(p)));
|
||||
set(el, 'transform', `scale(${lerp(0.9, 1, eoc(p)).toFixed(4)})`);
|
||||
set(el, 'transform', `scale(${lerp(0.86, 1, eoc(p)).toFixed(4)})`);
|
||||
}
|
||||
const done = SHOTS_AT + SHOT_SIZES.length * SHOTS_STEP + 0.2;
|
||||
pillAt(storePill, r, done);
|
||||
pillAt(shotCmd, r, done + 0.2, 'translateX(-50%)');
|
||||
}
|
||||
|
||||
// ---------- 5 products ----------
|
||||
const prodBody = $('#prodterm .body'), prodPill = $('#prod-pill');
|
||||
const prodLine = l => (l.cmd ? `<span class="ps">$</span> <span class="cmd">${esc(l.text.replace(/^\$ /, ''))}</span>`
|
||||
: tagged(l.text).replace(/^ok {4}/, '<span class="okk">ok</span> ').replace(/(\d+ regions?|\d+ region prices)/, '<span class="num">$1</span>')
|
||||
.replace(/(0 mismatch\(es\))/, '<span class="okk">$1</span>'));
|
||||
prodBody.innerHTML = PRODUCT_LINES.map((l, i) => `${i && l.cmd ? '<span class="ln" style="opacity:1"> </span>' : ''}<span class="ln">${prodLine(l)}</span>`).join('');
|
||||
const prodLns = [...prodBody.querySelectorAll('.ln')].filter(el => el.style.opacity !== '1');
|
||||
prodPill.innerHTML = bold(PRODUCTS_PILL.text);
|
||||
function products(t) {
|
||||
const s = scene('products'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear($('#prodterm'), r, 0.0, 0.4, 14);
|
||||
prodLns.forEach((el, i) => appear(el, r, PRODUCT_LINES[i].at, 0.3, 6));
|
||||
pillAt(prodPill, r, PRODUCTS_PILL.at);
|
||||
}
|
||||
|
||||
// ---------- 6 forms ----------
|
||||
const propBody = $('#prop .body'), formBody = $('#form .body'), formsPill = $('#forms-pill');
|
||||
// forms: the declarations proposal, then the Data safety rows it fills
|
||||
const propBody = $('#prop .body'), formBody = $('#form .body'), formCount = $('#form .count');
|
||||
propBody.innerHTML = DECL_LINES.map(d => `<span class="ln"><span class="v1"><span class="k">detected</span> <span class="w">${esc(d.what)}</span>:<span class="loc">(${esc(d.where)})</span></span><span class="v2">${esc(d.value)}</span></span>`).join('')
|
||||
+ `<span class="ln ask"><span class="glow"></span><span class="k">ask owner</span> <span class="w">${esc(DECL_ASK.replace(/^ask owner /, '').split(':')[0])}</span>:${esc(DECL_ASK.replace(/^ask owner [^:]+:/, ''))}</span>`
|
||||
+ '<span class="askn">Only you know this one: storebound asks.</span>';
|
||||
const propLns = [...propBody.querySelectorAll('.ln')], askGlow = $('#prop .ask .glow'), askNote = $('#prop .askn');
|
||||
formBody.innerHTML = DATA_SAFETY_ROWS.map(r => `<div class="row"><span class="box"><span class="fill"></span><span class="pi i-check"></span></span>${esc(r)}<span class="tg">detected</span></div>`).join('');
|
||||
+ `<span class="ln ask"><span class="glow"></span><span class="k">ask owner</span> <span class="w">${esc(DECL_ASK.replace(/^ask owner /, '').split(':')[0])}</span>:${esc(DECL_ASK.replace(/^ask owner [^:]+:/, ''))}</span>`;
|
||||
const propLns = [...propBody.querySelectorAll('.ln')], askGlow = $('#prop .ask .glow');
|
||||
formBody.innerHTML = DATA_SAFETY_ROWS.map(r => `<div class="row"><span class="box"><span class="fill"></span><span class="pi i-check"></span></span>${esc(r)}</div>`).join('');
|
||||
const formRows = [...formBody.querySelectorAll('.row')];
|
||||
function forms(t) {
|
||||
const s = scene('forms'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear($('#prop'), r, 0.0, 0.4, 14);
|
||||
appear($('#form'), r, 0.25, 0.4, 14);
|
||||
propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT + i * DECL_STEP : ASK_AT, 0.3, 6));
|
||||
const g = prog(r, ASK_AT + 0.15, 0.4);
|
||||
set(askGlow, 'opacity', String(eoc(g)));
|
||||
appear(askNote, r, ASK_AT + 0.4, 0.4, 6);
|
||||
appear($('#prop'), r, -0.1, 0.2, 10);
|
||||
appear($('#form'), r, 0.0, 0.2, 10);
|
||||
propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT + i * DECL_STEP : ASK_AT, 0.2, 6));
|
||||
set(askGlow, 'opacity', String(eoc(prog(r, ASK_AT + 0.1, 0.3))));
|
||||
formRows.forEach((el, i) => {
|
||||
const p = prog(r, FORM_AT + i * FORM_STEP, 0.25);
|
||||
const p = prog(r, FORM_AT + i * FORM_STEP, 0.2);
|
||||
set(el.querySelector('.fill'), 'opacity', String(eoc(p)));
|
||||
set(el.querySelector('.pi'), 'opacity', String(eoc(p)));
|
||||
set(el.querySelector('.box'), 'borderColor', p > 0 ? 'var(--green)' : 'var(--line-2)');
|
||||
set(el.querySelector('.tg'), 'opacity', String(p));
|
||||
set(el, 'color', p > 0.5 ? '#fff' : 'var(--ink-2)');
|
||||
});
|
||||
const np = prog(r, FORMS_PILL.at, 0.8);
|
||||
html(formsPill, `<b>${Math.round(DECL_TOTAL * eoc(np))}</b> ${esc(FORMS_PILL.text)}`);
|
||||
pillAt(formsPill, r, FORMS_PILL.at);
|
||||
const np = prog(r, FORMS_COUNT_AT, 0.7);
|
||||
html(formCount, `<b>${Math.round(DECL_TOTAL * eoc(np))}</b> answers`);
|
||||
appear(formCount, r, FORMS_COUNT_AT, 0.2, 6);
|
||||
}
|
||||
|
||||
// ---------- 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');
|
||||
// consoles: the plan in the terminal, then the app's plan panel
|
||||
const cpBody = $('#cpterm .body'), cpTerm = $('#cpterm');
|
||||
const cpLine = l => {
|
||||
if (l.final) return `<span class="glow"></span><span class="k">${esc(l.text.replace(/: Publish$/, ':'))}</span> <span class="w">Publish</span>`;
|
||||
if (l.head) return `<span class="num">${esc(l.text)}</span>`;
|
||||
if (/^Never press:/.test(l.text)) return esc(l.text).replace(/^(Never press:)/, '<span class="bad">$1</span>');
|
||||
return esc(l.text).replace(/^(\s+\d+\.)/, '<span class="tag">$1</span>').replace(/^(\s+)\[ok\]/, '$1<span class="okk">[ok]</span>')
|
||||
.replace(/^(\s+)\[ask\]/, '$1<span class="warnk">[ask]</span>').replace(/(\[click: [^\]]+\])/, '<span class="loc">$1</span>');
|
||||
return esc(l.text).replace(/^(\s+\d+\.)/, '<span class="tag">$1</span>').replace(/^(\s+)\[ok\]/, '$1<span class="okk">[ok]</span>').replace(/(\[click: [^\]]+\])/, '<span class="loc">$1</span>');
|
||||
};
|
||||
cpBody.innerHTML = `<span class="ln" data-k="c"></span>${CONSOLE_LINES.map(l => `<span class="ln${l.final ? ' fin' : ''}">${cpLine(l)}</span>`).join('')}`;
|
||||
const cpLns = [...cpBody.querySelectorAll('.ln')], cpGlow = $('#cpterm .fin .glow');
|
||||
cpPill.innerHTML = bold(CONSOLES_PILL.text);
|
||||
cpBody.innerHTML = `<span class="ln" style="opacity:1">${cmdLine(CONSOLE_CMD)}</span>${CONSOLE_LINES.map(l => `<span class="ln${l.final ? ' fin' : ''}">${cpLine(l)}</span>`).join('')}`;
|
||||
const cpLns = [...cpBody.querySelectorAll('.ln')].slice(1), cpGlow = $('#cpterm .fin .glow');
|
||||
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], `<span class="ps">$</span> <span class="cmd">${esc(CONSOLE_CMD.slice(0, n))}</span>${n < CONSOLE_CMD.length ? '<span class="caret"></span>' : ''}`);
|
||||
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));
|
||||
set(cpTerm, 'opacity', String(eoc(prog(r, -0.1, 0.2)) * out));
|
||||
cpLns.forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at, 0.15, 5));
|
||||
set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.08, 0.2))));
|
||||
}
|
||||
|
||||
// ---------- 8 rejections ----------
|
||||
// ---------- 6 scan: every finding turns green, the re-scan is clean ----------
|
||||
const scanBody = $('#scanterm .body');
|
||||
scanBody.innerHTML = `<span class="ln"><span class="ps">$</span> <span class="cmd">${esc(SCAN_CMD)}</span></span>`
|
||||
+ `<span class="ln num risk">${esc(RISKS_LINE).replace(/(2 blocker, 3 high)/, '<span class="bad">$1</span>')}</span>`
|
||||
+ FINDINGS.map((f, i) => `<div class="fd"><span class="tint b"></span><span class="tint g"></span>
|
||||
<div class="l1"><span class="tag">${i + 1}.</span><span class="sev${f.sev === 'HIGH' ? ' high' : ''}">${f.sev}</span><span class="gl">${esc(f.guideline)}</span><span class="ttl">${esc(f.title)}</span></div>
|
||||
<div class="why"><span class="pi i-stop"></span>${esc(f.why)}<span class="loc at">at ${esc(f.where)}</span><span class="fixed">FIXED</span></div></div>`).join('')
|
||||
+ `<span class="ln" id="rescan-c" style="margin-top:14px"><span class="ps">$</span> <span class="cmd">${esc(SCAN_CMD)}</span> <span class="tag">(after the fixes)</span></span>`
|
||||
+ `<span class="ln okk" id="rescan-r">${esc(RESCAN_LINE)}</span>`;
|
||||
scanBody.innerHTML = `<span class="ln">${cmdLine(SCAN_CMD)}</span>`
|
||||
+ `<span class="ln num">${esc(RISKS_LINE).replace(/(2 blocker, 3 high)/, '<span class="bad">$1</span>')}</span>`
|
||||
+ FINDINGS.map(f => {
|
||||
const m = f.match(/^(\S+)(\s+)(.*)$/);
|
||||
return `<div class="fd"><span class="tint b"></span><span class="tint g"></span><span class="pi i-stop"></span><span class="tx"><span class="sev">${m[1]}</span>${m[2]}${esc(m[3])}</span></div>`;
|
||||
}).join('')
|
||||
+ `<span class="ln" style="margin-top:14px">${cmdLine(SCAN_CMD)} <span class="tag">(after the fixes)</span></span>`
|
||||
+ `<span class="ln okk">${esc(RESCAN_LINE)}</span>`;
|
||||
const scanLns = [...scanBody.querySelectorAll(':scope > .ln')], fdEls = [...scanBody.querySelectorAll('.fd')];
|
||||
function rejections(t) {
|
||||
const s = scene('rejections'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
function scan(t) {
|
||||
const s = scene('scan'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear($('#scanterm'), r, 0.0, 0.4, 14);
|
||||
appear(scanLns[0], r, 0.2, 0.25, 4);
|
||||
appear(scanLns[1], r, 0.45, 0.3, 6);
|
||||
appear($('#scanterm'), r, -0.1, 0.2, 10);
|
||||
appear(scanLns[0], r, 0.05, 0.15, 4);
|
||||
appear(scanLns[1], r, 0.25, 0.2, 6);
|
||||
fdEls.forEach((el, i) => {
|
||||
appear(el, r, FINDINGS[i].at, 0.4, 10);
|
||||
const f = prog(r, FIX_AT[i], 0.45);
|
||||
appear(el, r, FINDINGS_AT + i * FINDINGS_STEP, 0.25, 8);
|
||||
const f = prog(r, FIX_AT[i], 0.3);
|
||||
set(el.querySelector('.tint.g'), 'opacity', String(eoc(f)));
|
||||
set(el.querySelector('.tint.b'), 'opacity', String(1 - eoc(f)));
|
||||
set(el.querySelector('.fixed'), 'opacity', String(eoc(f)));
|
||||
set(el.querySelector('.why .pi'), 'color', f > 0.5 ? 'var(--green)' : 'var(--red)');
|
||||
const ic = el.querySelector('.pi');
|
||||
if (f > 0.4) { ic.className = 'pi i-ok'; set(ic, 'color', 'var(--green)'); } else { ic.className = 'pi i-stop'; set(ic, 'color', 'var(--red)'); }
|
||||
set(el.querySelector('.tx'), 'color', f > 0.4 ? '#d3f9e2' : '#ffd6d7');
|
||||
});
|
||||
appear(scanLns[2], r, RESCAN_AT, 0.3, 6);
|
||||
appear(scanLns[3], r, RESCAN_AT + 0.35, 0.3, 6);
|
||||
appear(scanLns[2], r, RESCAN_AT - 0.25, 0.2, 6);
|
||||
appear(scanLns[3], r, RESCAN_AT, 0.25, 6);
|
||||
}
|
||||
|
||||
// ---------- 7, 9-13 desktop: real frames in a window, one push per scene, callouts, hover sweeps, a resting pointer ----------
|
||||
// ---------- desktop: real frames in a window, one push per scene, rings, the 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 };
|
||||
WIN.h = VIEW.h + WIN.bar;
|
||||
const S0 = VIEW.w / 1280;
|
||||
const sweepOf = g => {
|
||||
if (!g.sweep) return null;
|
||||
const f = BOXES.sweeps?.[g.sweep.seq];
|
||||
if (!f?.length) throw new Error(`boxes.json: no "${g.sweep.seq}" sweep (bun media/teaser/desktop.ts)`);
|
||||
return f;
|
||||
};
|
||||
const WINS = { consoles: { x: 420, y: 80, w: 1080 }, button: { x: 370, y: 250, w: 1180 }, waiting: { x: 370, y: 250, w: 1180 } };
|
||||
const desks = DESK_SCENES.map(id => {
|
||||
const d = DESK[id], cam = $(`#s-${id} > .cam`);
|
||||
const groups = d.groups.map(g => ({ g, sweep: sweepOf(g) }));
|
||||
// the window lives in the scene's .dhost (the consoles scene has its terminal next to it), else in the camera
|
||||
const WIN = { ...WINS[id], bar: 40 };
|
||||
WIN.vh = WIN.w * 800 / 1280; WIN.h = WIN.vh + WIN.bar; WIN.s0 = WIN.w / 1280;
|
||||
const host = cam.querySelector('.dhost') ?? cam.appendChild(Object.assign(document.createElement('div'), { className: 'dhost' }));
|
||||
host.innerHTML = `<div class="win" style="left:${WIN.x}px;top:${WIN.y}px;width:${WIN.w}px;height:${WIN.h}px">
|
||||
<div class="bar"><i></i><i></i><i></i><span class="tt">storebound</span></div>
|
||||
<div class="view"><div class="cam2">${groups.map((x, gi) => `<div class="grp" data-g="${gi}"><img src="out/desktop/${x.g.frame}.jpg" alt="">${(x.sweep ?? []).map(f => `<img src="out/desktop/${f.file}.jpg" alt="" style="opacity:0">`).join('')}</div>`).join('')}</div></div></div>
|
||||
${d.callouts.map(c => `<div class="ring${c.green ? ' green' : ''}"></div><div class="callout${c.green ? ' green' : ''}">${esc(c.text)}</div>`).join('')}
|
||||
${groups.some(x => x.sweep) || d.pointer ? '<span class="dcur" aria-hidden="true"><span class="pi i-cursor"></span></span>' : ''}`;
|
||||
if (d.pointer && !BOXES[d.pointer.target]) throw new Error(`boxes.json: no "${d.pointer.target}" (bun media/teaser/desktop.ts)`);
|
||||
<div class="view"><div class="cam2">${d.groups.map((g, gi) => `<div class="grp" data-g="${gi}"><img src="out/desktop/${g.frame}.jpg" alt=""></div>`).join('')}</div></div></div>
|
||||
${d.rings.map(c => `<div class="ring${c.green ? ' green' : ''}"></div>`).join('')}
|
||||
${d.pointer ? '<span class="dcur" aria-hidden="true"><span class="pi i-cursor"></span></span>' : ''}`;
|
||||
for (const k of [...d.rings.map(c => c.target), d.pointer?.target].filter(Boolean)) if (!BOXES[k]) throw new Error(`boxes.json: no "${k}" (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') };
|
||||
return { id, d, WIN, host, cam2: host.querySelector('.cam2'), grpEls, rings: [...host.querySelectorAll('.ring')], cur: host.querySelector('.dcur') };
|
||||
});
|
||||
function deskView(dk, r) {
|
||||
const s = scene(dk.id), len = s.end - s.start;
|
||||
const p = ecam(clamp((r + DISSOLVE / 2) / (len + DISSOLVE)));
|
||||
const s = scene(dk.id), len = s.end - s.start, { WIN } = dk;
|
||||
const p = dk.id === 'consoles' ? ecam(clamp(r / len)) : esin(clamp(r / len));
|
||||
const { from, to } = dk.d.cam;
|
||||
const S = S0 * lerp(from.z, to.z, p), fx = lerp(from.f[0], to.f[0], p), fy = lerp(from.f[1], to.f[1], p);
|
||||
const ox = clamp(VIEW.w / 2 - fx * 1280 * S, VIEW.w - 1280 * S, 0), oy = clamp(VIEW.h / 2 - fy * 800 * S, VIEW.h - 800 * S, 0);
|
||||
const S = WIN.s0 * lerp(from.z, to.z, p), fx = lerp(from.f[0], to.f[0], p), fy = lerp(from.f[1], to.f[1], p);
|
||||
const ox = clamp(WIN.w / 2 - fx * 1280 * S, WIN.w - 1280 * S, 0), oy = clamp(WIN.vh / 2 - fy * 800 * S, WIN.vh - 800 * S, 0);
|
||||
return { S, ox, oy };
|
||||
}
|
||||
const stageBox = (b, c) => ({ x: WIN.x + c.ox + b.x * 1280 * c.S, y: WIN.y + WIN.bar + c.oy + b.y * 800 * c.S, w: b.w * 1280 * c.S, h: b.h * 800 * c.S });
|
||||
/** A hover sweep at r: the pointer glides from the first hovered day's position to the last one's (ease in-out); the
|
||||
* frame is the last day whose position the pointer has reached, as on the real chart. */
|
||||
function sweepAt(sw, f, r) {
|
||||
const p = eio(prog(r, sw.at, sw.d));
|
||||
const px = lerp(f[0].px, f.at(-1).px, p), py = lerp(f[0].py, f.at(-1).py, p);
|
||||
let k = -1;
|
||||
if (r >= sw.at) { k = 0; while (k + 1 < f.length && f[k + 1].px <= px + 1e-9) k++; }
|
||||
return { px, py, k };
|
||||
}
|
||||
const stageBox = (W0, b, c) => ({ x: W0.x + c.ox + b.x * 1280 * c.S, y: W0.y + W0.bar + c.oy + b.y * 800 * c.S, w: b.w * 1280 * c.S, h: b.h * 800 * c.S });
|
||||
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);
|
||||
const r = t - s.start, d = dk.d, WIN = dk.WIN;
|
||||
const shown = d.show ? eio(prog(r, d.show[0], d.show[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;
|
||||
if (dk.grpEls[1]) set(dk.grpEls[1], 'opacity', String(sw));
|
||||
// the pointer: fades in just before its group's sweep, rests after it, fades with the dissolve to the next group
|
||||
let cur = null;
|
||||
dk.groups.forEach((x, gi) => {
|
||||
if (!x.sweep) return;
|
||||
const st = sweepAt(x.g.sweep, x.sweep, r);
|
||||
dk.imgs[gi].forEach((im, i) => set(im, 'opacity', i === 0 || i === st.k + 1 ? '1' : '0'));
|
||||
const vis = gi === 0 && d.swap ? 1 - prog(r, d.swap[0] - 0.2, 0.25) : 1;
|
||||
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) {
|
||||
if (dk.grpEls[1]) set(dk.grpEls[1], 'opacity', String(d.swap ? eio(prog(r, d.swap[0], d.swap[1] - d.swap[0])) : 0));
|
||||
// the pointer: glides in from below right onto its target and rests; a press = a short squeeze
|
||||
if (dk.cur) {
|
||||
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 };
|
||||
const op = prog(r, pt.at - 0.3, 0.3) * (pt.until ? 1 - prog(r, pt.until, 0.3) : 1);
|
||||
const press = pt.click !== undefined ? Math.sin(Math.PI * prog(r, pt.click, 0.16)) : 0;
|
||||
set(dk.cur, 'opacity', String(op));
|
||||
const sb = stageBox(WIN, { x: lerp(Math.min(tx + 0.16, 0.97), tx, p), y: lerp(Math.min(ty + 0.2, 0.97), ty, p), w: 0, h: 0 }, c);
|
||||
set(dk.cur, 'transform', `translate(${(sb.x - 10).toFixed(1)}px, ${(sb.y - 10).toFixed(1)}px) scale(${(1 - press * 0.18).toFixed(3)})`);
|
||||
}
|
||||
if (dk.cur) {
|
||||
set(dk.cur, 'opacity', String(cur ? cur.op : 0));
|
||||
if (cur) {
|
||||
const b = stageBox({ x: cur.px, y: cur.py, w: 0, h: 0 }, c);
|
||||
set(dk.cur, 'transform', `translate(${(b.x - 10).toFixed(1)}px, ${(b.y - 10).toFixed(1)}px)`);
|
||||
}
|
||||
}
|
||||
d.callouts.forEach((co, i) => {
|
||||
const b = stageBox(BOXES[co.target], c);
|
||||
d.rings.forEach((co, i) => {
|
||||
const b = stageBox(WIN, BOXES[co.target], c);
|
||||
const op = eoc(prog(r, co.at, 0.4)) * (1 - prog(r, co.until - 0.3, 0.3));
|
||||
const ring = dk.rings[i], call = dk.callouts[i];
|
||||
const ring = dk.rings[i];
|
||||
const x0 = Math.max(b.x - 10, WIN.x + 8), y0 = Math.max(b.y - 10, WIN.y + WIN.bar + 6);
|
||||
const x1 = Math.min(b.x + b.w + 10, WIN.x + WIN.w - 8), y1 = Math.min(b.y + b.h + 10, WIN.y + WIN.h - 8);
|
||||
set(ring, 'opacity', String(op));
|
||||
set(ring, 'left', `${x0.toFixed(1)}px`); set(ring, 'top', `${y0.toFixed(1)}px`);
|
||||
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 = !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`);
|
||||
set(call, 'transform', `translate(-50%, ${below ? '0' : '-100%'}) scale(${lerp(0.92, 1, eoc(prog(r, co.at, 0.4))).toFixed(3)})`);
|
||||
});
|
||||
}
|
||||
const desktop = t => desks.forEach(dk => deskScene(dk, t));
|
||||
|
||||
// ---------- 13 submit: the end card (the app window is DESK.submit; it fades out as the card comes in) ----------
|
||||
// ---------- 9 end card ----------
|
||||
const brand = $('#brand');
|
||||
brand.querySelector('.url').textContent = URL;
|
||||
brand.querySelector('.plat').textContent = TAGLINE;
|
||||
function submit(t) {
|
||||
const s = scene('submit');
|
||||
brand.querySelector('.line').textContent = END_LINE;
|
||||
brand.querySelector('.url').textContent = END_URL;
|
||||
const brandEls = [['.mark', END.markAt], ['.word', END.wordAt], ['.line', END.lineAt], ['.url', END.urlAt]].map(([s, at]) => [brand.querySelector(s), at]);
|
||||
function end(t) {
|
||||
const s = scene('end'); if (t < s.start - 1) return;
|
||||
const r = t - s.start;
|
||||
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)));
|
||||
set(el, 'transform', `translateY(${((1 - eoc(q2)) * 18).toFixed(1)}px)`);
|
||||
});
|
||||
for (const [el, at] of brandEls) appear(el, r, at, END.d, 18);
|
||||
}
|
||||
|
||||
function seek(t) {
|
||||
scenes(t);
|
||||
background(t);
|
||||
hook(t);
|
||||
init(t);
|
||||
plan(t);
|
||||
store(t);
|
||||
game(t);
|
||||
pile(t);
|
||||
reject(t);
|
||||
ask(t);
|
||||
builds(t);
|
||||
shots(t);
|
||||
products(t);
|
||||
forms(t);
|
||||
consoles(t);
|
||||
rejections(t);
|
||||
scan(t);
|
||||
desktop(t);
|
||||
submit(t);
|
||||
end(t);
|
||||
captions(t);
|
||||
}
|
||||
|
||||
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, '') }));
|
||||
// a few at a time, each retried: Chrome refuses some of ~90 parallel 2560x1600 decodes under load (EncodingError)
|
||||
const bgs = [...document.querySelectorAll('.tile, .phone')].map(el => Object.assign(new Image(), { src: el.style.backgroundImage.replace(/^url\("?|"?\)$/g, '') }));
|
||||
// a few at a time, each retried: Chrome refuses some of many parallel large decodes under load (EncodingError)
|
||||
const all = [...imgs, ...bgs];
|
||||
const decode = async im => {
|
||||
for (let i = 0; ; i++) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
// 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.
|
||||
// The teaser video (media/teaser, v8, a story): every CLI line it shows is real output of the command it names, the game
|
||||
// screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app (desktop.ts),
|
||||
// every cut lands on a beat of the music, each caption is short and fully on screen for at least 2.5 s, the whole cut is
|
||||
// 56-62 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';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { ALPHA3 } from '../cli/lib/money';
|
||||
import { SIZES } from '../cli/shots/lib';
|
||||
|
||||
|
|
@ -14,25 +15,25 @@ const CLI = join(ROOT, 'cli', 'index.ts');
|
|||
const FX = join(import.meta.dir, 'fixtures', 'scan');
|
||||
const MOCK = join(import.meta.dir, 'helpers', 'teaser-store-mock.ts');
|
||||
|
||||
interface Scene { id: string; start: number; end: number }
|
||||
interface Scene { id: string; start: number; end: number; x: number }
|
||||
type Line = { at: number; text: string; cmd?: boolean; head?: boolean; final?: boolean };
|
||||
interface Teaser {
|
||||
FORMAT: { w: number; h: number }; DURATION: number; SCENES: Scene[]; DISSOLVE: number;
|
||||
CAPTIONS: { id: string; lines: string[] }[];
|
||||
captionTimes(): { id: string; text: string; at: number; from: number; to: number; hold: number }[];
|
||||
TODO: { text: string }[]; MAIL: { at: number; subject: string; guideline: string }; MAIL_IN: number;
|
||||
INIT_CMD: string; INIT_LINES: string[];
|
||||
PLAN_CMD: string; PLAN_LINES: string[]; PLAN_STEPS: number; PLAN_PILL: string;
|
||||
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<string, { groups: { frame: string; sweep?: { seq: string; at: number; d: number } }[]; swap?: number[]; show?: number[]; hide?: number[];
|
||||
callouts: { at: number; until: number; target: string; text: string }[]; pointer?: { at: number; d: number; target: string; until?: number } }>;
|
||||
SUBMIT: { logoAt: number };
|
||||
DESK_SCENES: string[]; DESK_FRAMES: string[]; DESK_SWEEPS: string[];
|
||||
FORMAT: { w: number; h: number }; DURATION: number; SCENES: Scene[]; MONTAGE: string[];
|
||||
CAPTIONS: { id: string; pos: 'top' | 'edge'; text: string; at: number; until: number }[];
|
||||
captionTimes(): { id: string; text: string; pos: string; at: number; from: number; to: number; gone: number; hold: number }[];
|
||||
veil(t: number): number;
|
||||
GAME_SCREENS: string[];
|
||||
PRIVACY_ANSWERS: string[]; PRIVACY_STEPS: string[]; DATA_SAFETY_CSV: string[]; DECL_TOTAL: number; PRICE_ROWS: string[]; PRICE_CMD: string;
|
||||
PILE: { at: number; kind: string }[]; PILE_TITLES: Record<string, string>;
|
||||
REJECT: { head: string; at: string }; PROMPT: string;
|
||||
BUILD_LINES: Line[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; PRODUCT_LINES: Line[];
|
||||
DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string;
|
||||
CONSOLE_CMD: string; CONSOLE_LINES: Line[]; CONSOLES_SWAP: number[];
|
||||
SCAN_CMD: string; RISKS_LINE: string; RESCAN_LINE: string; FINDINGS: string[]; FIX_AT: number[];
|
||||
DESK: Record<string, { groups: { frame: string }[]; swap?: number[]; show?: number[]; rings: { at: number; until: number; target: string }[];
|
||||
pointer?: { at: number; d: number; target: string; click?: number; until?: number } }>;
|
||||
DESK_SCENES: string[]; DESK_FRAMES: string[]; CLICK_T: number;
|
||||
END: { markAt: number; wordAt: number; lineAt: number; urlAt: number; d: number }; END_LINE: string; END_URL: string;
|
||||
}
|
||||
const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser;
|
||||
|
||||
|
|
@ -42,13 +43,25 @@ const run = (argv: string[], cwd: string, env: Record<string, string> = {}): { c
|
|||
const r = Bun.spawnSync(['bun', CLI, ...argv], { cwd, env: { ...process.env, NO_COLOR: '1', ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ...env }, stdout: 'pipe', stderr: 'pipe' });
|
||||
return { code: r.exitCode, out: r.stdout.toString() + r.stderr.toString() };
|
||||
};
|
||||
/** Every line is in the output, in the given order. */
|
||||
function inOrder(out: string[], want: string[]): void {
|
||||
for (const l of want) expect(out, l).toContain(l);
|
||||
const idx = want.map(l => out.indexOf(l));
|
||||
expect([...idx].sort((a, b) => a - b)).toEqual(idx);
|
||||
}
|
||||
|
||||
// One throwaway project for the init, plan and products scenes: `storebound init` in an empty folder, store answers
|
||||
// from the teaser store double (tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml), placeholder keys.
|
||||
let dir = '', initOut = '', keys: Record<string, string> = {};
|
||||
// One throwaway project: `storebound init` in an empty folder plus two products, store answers from the teaser store
|
||||
// double (tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml), placeholder keys.
|
||||
let dir = '', keys: Record<string, string> = {};
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'sb-teaser-'));
|
||||
initOut = run(['init'], dir).out;
|
||||
expect(run(['init'], dir).code).toBe(0);
|
||||
const cfg = join(dir, 'storebound.config.ts');
|
||||
writeFileSync(cfg, readFileSync(cfg, 'utf8').replace(' dataSafety: {', ` products: [
|
||||
{ id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes every ad.' }, tr: { title: 'Reklamsız', description: 'Tüm reklamları kaldırır.' } } },
|
||||
{ id: 'com.example.app.gems_100', type: 'consumable', usd: 0.99, listings: { en: { title: '100 Gems', description: 'A pouch of 100 gems.' }, tr: { title: '100 Mücevher', description: '100 mücevherlik bir kese.' } } },
|
||||
],
|
||||
dataSafety: {`));
|
||||
writeFileSync(join(dir, 'bunfig.toml'), `preload = [${JSON.stringify(MOCK)}]\n`);
|
||||
const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
|
|
@ -59,74 +72,72 @@ beforeAll(() => {
|
|||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
describe('teaser: every CLI line is real output', () => {
|
||||
test('init: `storebound init` in an empty folder', () => {
|
||||
expect(T.INIT_CMD).toBe('bunx storebound init');
|
||||
const out = lines(initOut);
|
||||
for (const l of T.INIT_LINES) expect(out, l).toContain(l);
|
||||
});
|
||||
|
||||
test('plan: `storebound release plan 1.0.0` against a brand-new app record on both stores', () => {
|
||||
expect(T.PLAN_CMD).toBe('storebound release plan 1.0.0');
|
||||
const r = run(T.PLAN_CMD.split(' ').slice(1), dir, { ...keys, TEASER_STORE: 'new' });
|
||||
const out = lines(r.out);
|
||||
for (const l of T.PLAN_LINES) expect(out, l).toContain(l);
|
||||
// the lines keep the order of the real plan
|
||||
const idx = T.PLAN_LINES.map(l => out.indexOf(l));
|
||||
expect([...idx].sort((a, b) => a - b)).toEqual(idx);
|
||||
const steps = out.filter(l => /^\s+\d+\. /.test(l)).length;
|
||||
expect(steps).toBe(T.PLAN_STEPS);
|
||||
expect(T.PLAN_PILL).toStartWith(`*${steps}* steps`);
|
||||
expect(r.out).not.toContain('could not be read');
|
||||
});
|
||||
|
||||
test('products: `play products --dry` before, `catalog diff` after, with two products in the config', () => {
|
||||
const cfg = join(dir, 'storebound.config.ts');
|
||||
const src = readFileSync(cfg, 'utf8');
|
||||
writeFileSync(cfg, src.replace(' dataSafety: {', ` products: [
|
||||
{ id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes every ad.' }, tr: { title: 'Reklamsız', description: 'Tüm reklamları kaldırır.' } } },
|
||||
{ id: 'com.example.app.gems_100', type: 'consumable', usd: 0.99, listings: { en: { title: '100 Gems', description: 'A pouch of 100 gems.' }, tr: { title: '100 Mücevher', description: '100 mücevherlik bir kese.' } } },
|
||||
],
|
||||
dataSafety: {`));
|
||||
try {
|
||||
const cmds = T.PRODUCT_LINES.filter(l => l.cmd).map(l => l.text.replace(/^\$ storebound /, ''));
|
||||
expect(cmds).toEqual(['play products --dry', 'catalog diff']);
|
||||
const before = run(cmds[0]!.split(' '), dir, { ...keys, TEASER_STORE: 'new' });
|
||||
const after = run(cmds[1]!.split(' '), dir, { ...keys, TEASER_STORE: 'catalog' });
|
||||
expect(before.code).toBe(0);
|
||||
expect(after.code).toBe(0);
|
||||
const out = [...lines(before.out), ...lines(after.out)];
|
||||
for (const l of T.PRODUCT_LINES.filter(x => !x.cmd)) expect(out, l.text).toContain(l.text);
|
||||
// the pill's numbers are the region counts above, and the App Store's 175 storefronts
|
||||
expect(Object.keys(ALPHA3)).toHaveLength(175);
|
||||
expect(T.PRODUCTS_PILL.text).toContain('*175* App Store regions');
|
||||
expect(T.PRODUCTS_PILL.text).toContain('*174* Google Play regions');
|
||||
expect(out.join('\n')).toContain('0 mismatch(es), 0 warning(s), 0 price difference(s)');
|
||||
} finally { writeFileSync(cfg, src); }
|
||||
});
|
||||
|
||||
test('consoles: `storebound console plan apple-app-privacy` (offline): steps, answer sheet, never-press, the owner\'s Publish', () => {
|
||||
test('pile + consoles: `storebound console plan apple-app-privacy` (offline): answer sheet, steps, 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
|
||||
inOrder(out, T.PRIVACY_ANSWERS);
|
||||
inOrder(out, T.PRIVACY_STEPS);
|
||||
inOrder(out, T.CONSOLE_LINES.map(l => l.text));
|
||||
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`', () => {
|
||||
test('pile: the Data safety CSV of `storebound play data-safety --dry`, its row count from `storebound check`', () => {
|
||||
const r = run(['play', 'data-safety', '--dry'], dir);
|
||||
expect(r.code).toBe(0);
|
||||
inOrder(lines(r.out), T.DATA_SAFETY_CSV);
|
||||
expect(run(['check'], dir).out).toContain(`data safety: ${T.DECL_TOTAL} CSV rows`);
|
||||
expect(T.PILE_TITLES.csv).toContain(`${T.DECL_TOTAL} rows`);
|
||||
});
|
||||
|
||||
test('pile: the price sheet of `storebound prices ppp --offline --dry`', () => {
|
||||
const r = run(T.PRICE_CMD.split(' '), dir);
|
||||
expect(r.code).toBe(0);
|
||||
inOrder(lines(r.out), T.PRICE_ROWS);
|
||||
});
|
||||
|
||||
test('builds: `storebound apple build --dry` and `storebound play build --dry` in a Capacitor project', () => {
|
||||
const d = mkdtempSync(join(tmpdir(), 'sb-teaser-build-'));
|
||||
try {
|
||||
const touch = (p: string, s = '') => { mkdirSync(dirname(join(d, p)), { recursive: true }); writeFileSync(join(d, p), s); };
|
||||
touch('storebound.config.ts', `export default { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
|
||||
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], apple: { bundleId: 'com.example.game', signing: { teamId: 'TEAMID1234' } }, play: { packageName: 'com.example.game' } };\n`);
|
||||
touch('capacitor.config.ts', 'export default {}');
|
||||
touch('ios/App/App.xcodeproj/project.pbxproj');
|
||||
touch('android/settings.gradle'); touch('android/gradlew', '#!/bin/sh');
|
||||
touch('android/app/build.gradle', "android {\n defaultConfig {\n versionCode((project.findProperty('versionCode') ?: 1) as Integer)\n versionName project.findProperty('versionName') ?: '1.0'\n }\n}\n");
|
||||
const cmds = T.BUILD_LINES.filter(l => l.cmd).map(l => l.text.replace(/^\$ storebound /, ''));
|
||||
expect(cmds).toEqual(['apple build --dry --build-number 52 --version 1.0.0', 'play build --dry --version-code 52']);
|
||||
const outs = cmds.map(c => run(c.split(' '), d, { HOME: join(d, 'home') }));
|
||||
for (const o of outs) expect(o.code).toBe(0);
|
||||
// each command's lines in its own output, in order
|
||||
const groups: string[][] = [];
|
||||
for (const l of T.BUILD_LINES) if (l.cmd) groups.push([]); else groups.at(-1)!.push(l.text);
|
||||
groups.forEach((g, i) => {
|
||||
const out = lines(outs[i]!.out);
|
||||
for (const l of g) expect(out, l).toContain(l);
|
||||
expect(out.indexOf(g[0]!)).toBeLessThan(out.lastIndexOf(g.at(-1)!));
|
||||
});
|
||||
} finally { rmSync(d, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('products: `play products --dry` before, `catalog diff` after, with the two products in the config', () => {
|
||||
const cmds = T.PRODUCT_LINES.filter(l => l.cmd).map(l => l.text.replace(/^\$ storebound /, ''));
|
||||
expect(cmds).toEqual(['play products --dry', 'catalog diff']);
|
||||
const before = run(cmds[0]!.split(' '), dir, { ...keys, TEASER_STORE: 'new' });
|
||||
const after = run(cmds[1]!.split(' '), dir, { ...keys, TEASER_STORE: 'catalog' });
|
||||
expect(before.code).toBe(0);
|
||||
expect(after.code).toBe(0);
|
||||
const out = [...lines(before.out), ...lines(after.out)];
|
||||
for (const l of T.PRODUCT_LINES.filter(x => !x.cmd)) expect(out, l.text).toContain(l.text);
|
||||
// "175 countries": the App Store's storefronts, the region prices shown
|
||||
expect(Object.keys(ALPHA3)).toHaveLength(175);
|
||||
expect(T.PRODUCT_LINES.some(l => l.text.includes('175 region prices'))).toBe(true);
|
||||
expect(T.CAPTIONS.find(c => c.id === 'products')!.text).toBe('Products, 175 countries.');
|
||||
expect(out.join('\n')).toContain('0 mismatch(es), 0 warning(s), 0 price difference(s)');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -140,19 +151,18 @@ describe('teaser: the scan text is real', () => {
|
|||
let before = '', after = '';
|
||||
beforeAll(() => { before = scanText('teaser', false); after = scanText('teaser-fixed', true); });
|
||||
|
||||
test('rejections: the risks line and three findings (severity, guideline, title, file:line)', () => {
|
||||
expect(T.SCAN_CMD).toBe('storebound scan');
|
||||
expect(before).toContain(T.RISKS_LINE);
|
||||
const blocks = before.split(/\n\s+\d\. /).slice(1);
|
||||
for (const f of T.FINDINGS) {
|
||||
const b = blocks.find(x => x.startsWith(`${f.sev.padEnd(7)} ${f.guideline} · App Store · ${f.title}`));
|
||||
expect(b, f.title).toBeDefined();
|
||||
expect(b!).toContain(`at ${f.where}`);
|
||||
// the plain reason is an annotation, short enough to read
|
||||
expect(f.why.split(' ').length, f.why).toBeLessThanOrEqual(10);
|
||||
}
|
||||
test('reject: the account-deletion finding, its own header and evidence line', () => {
|
||||
const out = lines(before);
|
||||
expect(out).toContain(` 2. ${T.REJECT.head}`);
|
||||
expect(out).toContain(` ${T.REJECT.at}`);
|
||||
expect(T.REJECT.head).toContain('5.1.1(v)');
|
||||
});
|
||||
test('rejections: the re-scan after the fixes (tests/fixtures/scan/teaser-fixed) is clean', () => {
|
||||
test('scan: the risks line, all five findings in order, and the clean re-scan of teaser-fixed', () => {
|
||||
expect(T.SCAN_CMD).toBe('storebound scan');
|
||||
const out = lines(before);
|
||||
expect(out).toContain(T.RISKS_LINE);
|
||||
inOrder(out, T.FINDINGS.map((f, i) => ` ${i + 1}. ${f}`));
|
||||
expect(T.FIX_AT).toHaveLength(T.FINDINGS.length);
|
||||
expect(after).toContain(T.RESCAN_LINE);
|
||||
});
|
||||
test('forms: the declarations proposal lines and the ask-owner line', () => {
|
||||
|
|
@ -162,18 +172,22 @@ describe('teaser: the scan text is real', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('teaser: the store text and screenshots are the demo game\'s', () => {
|
||||
test('copy: the hero title per language from examples/demo-game/store/shots/copy.json', () => {
|
||||
const copy = JSON.parse(readFileSync(join(ROOT, 'examples/demo-game', T.COPY_FILE), 'utf8')) as Record<string, { hero: { title: string[] } }>;
|
||||
for (const c of T.COPY) expect(c.title).toEqual(copy[c.lang]!.hero.title);
|
||||
expect(T.SHOT_LANGS).toEqual(T.COPY.map(c => c.lang));
|
||||
describe('teaser: the game and its screenshots are the demo game\'s', () => {
|
||||
test('game: the screens are the demo game\'s own captures (its config\'s capture shots)', async () => {
|
||||
const cfg = (await import(join(ROOT, 'examples/demo-game/storebound.config.ts'))).default as { languages: { id: string }[]; shots: { capture: { shots: Record<string, unknown> } } };
|
||||
expect(T.GAME_SCREENS).toEqual(Object.keys(cfg.shots.capture.shots));
|
||||
expect(T.SHOT_LANGS).toEqual(cfg.languages.map(l => l.id));
|
||||
const render = readFileSync(join(ROOT, 'media/teaser/render.ts'), 'utf8');
|
||||
expect(render).toContain("'examples/demo-game/store/shots/frames/en/phone'");
|
||||
});
|
||||
test('sizes: seven store sizes the demo game renders (iPhone, iPad, Play phone and tablets, feature graphic)', () => {
|
||||
test('shots: seven store sizes the demo game renders (iPhone, iPad, Play phone and tablets, feature graphic)', () => {
|
||||
for (const s of T.SHOT_SIZES) expect(SIZES[s.id], s.id).toBeDefined();
|
||||
expect(T.SHOT_SIZES).toHaveLength(7);
|
||||
expect(T.SHOTS_PILL).toBe(`*7* sizes × *${T.COPY.length}* languages`);
|
||||
expect(T.TODO.map(x => x.text)).toContain('7 screenshot sizes × 14 languages');
|
||||
expect(T.TODO.map(x => x.text)).toContain('prices in 175 countries');
|
||||
// the caption claims sizes, not a language count the demo does not have
|
||||
expect(T.CAPTIONS.find(c => c.id === 'shots')!.text).toBe('Screenshots, every size.');
|
||||
});
|
||||
test('ask: the request is a plain sentence to the agent', () => {
|
||||
expect(T.PROMPT).toBe('Get this game ready for review on both stores.');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -184,40 +198,70 @@ 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 (whole half seconds: each cut on a beat of music.js), 60-65 s in total', () => {
|
||||
test('the story: nine beats in order, the montage in the middle, 56-62 s', () => {
|
||||
expect(T.SCENES.map(s => s.id)).toEqual(['game', 'pile', 'reject', 'ask', 'builds', 'shots', 'products', 'forms', 'consoles', 'scan', 'button', 'waiting', 'end']);
|
||||
expect(T.MONTAGE).toEqual(['builds', 'shots', 'products', 'forms', 'consoles']);
|
||||
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
|
||||
expect(T.DURATION).toBeGreaterThanOrEqual(56);
|
||||
expect(T.DURATION).toBeLessThanOrEqual(62);
|
||||
// the montage spans 19-37 s and speeds up (no beat longer than the one before)
|
||||
const m = T.MONTAGE.map(id => T.SCENES.find(s => s.id === id)!);
|
||||
expect(m[0]!.start).toBe(19);
|
||||
expect(m.at(-1)!.end).toBe(37);
|
||||
for (let i = 1; i < m.length; i++) expect(m[i]!.end - m[i]!.start).toBeLessThanOrEqual(m[i - 1]!.end - m[i - 1]!.start);
|
||||
for (const s of m) expect(s.x, s.id).toBeLessThanOrEqual(0.15); // cuts, not dissolves
|
||||
});
|
||||
test('every cut lands on a beat of music.js (120 BPM: whole half seconds), scenes 3-6 s', () => {
|
||||
const music = readFileSync(join(ROOT, 'media/teaser/music.js'), 'utf8');
|
||||
expect(music).toContain('export const STEP = 0.5;');
|
||||
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(Number.isInteger(s.start / 0.5), s.id).toBe(true);
|
||||
expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(3);
|
||||
expect(s.end - s.start, s.id).toBeLessThanOrEqual(6);
|
||||
}
|
||||
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
|
||||
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}: '`));
|
||||
// the fixes chime on beats too
|
||||
const scan = T.SCENES.find(s => s.id === 'scan')!;
|
||||
for (const f of T.FIX_AT) expect(Number.isInteger((scan.start + f) / 0.5), String(f)).toBe(true);
|
||||
});
|
||||
test('one caption per scene, fully on screen for at least 3 s, inside its scene', () => {
|
||||
test('captions: at most one short line per scene, fully on screen for at least 2.5 s, inside its scene', () => {
|
||||
const c = T.captionTimes();
|
||||
expect(c.map(x => x.id)).toEqual(T.SCENES.map(s => s.id));
|
||||
expect(new Set(c.map(x => x.id)).size).toBe(c.length);
|
||||
for (const x of c) {
|
||||
const s = T.SCENES.find(y => y.id === x.id)!;
|
||||
expect(x.hold, x.text).toBeGreaterThanOrEqual(3);
|
||||
expect(x.at).toBeGreaterThanOrEqual(s.start);
|
||||
expect(x.to).toBeLessThanOrEqual(s.end);
|
||||
expect(x.hold, x.text).toBeGreaterThanOrEqual(2.5);
|
||||
expect(x.at, x.text).toBeGreaterThanOrEqual(s.start);
|
||||
expect(x.gone, x.text).toBeLessThanOrEqual(s.end);
|
||||
expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(8);
|
||||
}
|
||||
// the montage words sit at the edge, the story lines at the top; the click and the end card carry no caption
|
||||
for (const x of c) expect(x.pos, x.id).toBe(T.MONTAGE.includes(x.id) ? 'edge' : 'top');
|
||||
expect(c.map(x => x.id)).not.toContain('waiting');
|
||||
expect(c.map(x => x.id)).not.toContain('end');
|
||||
expect(c.map(x => x.text)).toEqual(['The game is done.', 'Then come the stores.', 'One missing line, and you start over.', 'So I handed it all to my AI.',
|
||||
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left for a reviewer to catch.', 'One button is yours.']);
|
||||
});
|
||||
test('the rejection mail holds at least 2 s before the first cut', () => {
|
||||
expect(T.SCENES[0]!.end - T.MAIL.at - T.MAIL_IN).toBeGreaterThanOrEqual(2);
|
||||
expect(T.MAIL.guideline).toStartWith('Guideline 2.1');
|
||||
test('the end card: the two lines on screen for at least 2.5 s', () => {
|
||||
const e = T.SCENES.find(s => s.id === 'end')!;
|
||||
expect(T.END_LINE).toBe('From an empty folder to Submit.');
|
||||
expect(T.END_URL).toBe('Open source · storebound.dev');
|
||||
expect(e.end - e.start - T.END.urlAt - T.END.d).toBeGreaterThanOrEqual(2.5);
|
||||
});
|
||||
test('the picture darkens over the rejection and lifts on the drop', () => {
|
||||
const r = T.SCENES.find(s => s.id === 'reject')!, b = T.SCENES.find(s => s.id === 'builds')!;
|
||||
expect(T.veil(r.start)).toBe(0);
|
||||
expect(T.veil(r.end - 0.1)).toBeGreaterThan(0.5);
|
||||
expect(T.veil(b.start)).toBe(0);
|
||||
});
|
||||
test('the pile speeds up: each card lands sooner after the one before', () => {
|
||||
for (let i = 2; i < T.PILE.length; i++) expect(T.PILE[i]!.at - T.PILE[i - 1]!.at).toBeLessThanOrEqual(T.PILE[i - 1]!.at - T.PILE[i - 2]!.at + 1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
// 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).
|
||||
// The desktop frames: the real app (fixture mode) in the consoles beat, the button and the waiting scene, captured by
|
||||
// media/teaser/desktop.ts. The frames are gitignored output; the file checks run wherever they were captured
|
||||
// (render.ts captures them when they are missing).
|
||||
const DESK_DIR = join(ROOT, 'media/teaser/out/desktop');
|
||||
const captured = existsSync(join(DESK_DIR, 'boxes.json'));
|
||||
/** Width and height of a JPEG (its first SOF marker). */
|
||||
|
|
@ -233,78 +277,48 @@ function jpegSize(buf: Buffer): { w: number; h: number } | null {
|
|||
}
|
||||
|
||||
describe('teaser: the desktop scenes are real captures of the app', () => {
|
||||
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).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
|
||||
test('consoles, the button and the click: the plan panel, Send for review, then Waiting for review', () => {
|
||||
expect(T.DESK_SCENES).toEqual(['consoles', 'button', 'waiting']);
|
||||
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']);
|
||||
expect(T.DESK.button!.groups.map(g => g.frame)).toEqual(['submit-ready']);
|
||||
expect(T.DESK.waiting!.groups.map(g => g.frame)).toEqual(['submit-ready', 'submit-waiting']);
|
||||
// the pointer rests on Submit in the button scene and presses it once, at the start of the waiting scene
|
||||
expect(T.DESK.button!.pointer!.target).toBe('submitBtn');
|
||||
expect(T.DESK.button!.pointer!.click).toBeUndefined();
|
||||
expect(T.DESK.waiting!.pointer!.target).toBe('submitBtn');
|
||||
const w = T.SCENES.find(s => s.id === 'waiting')!;
|
||||
expect(T.CLICK_T).toBe(w.start + T.DESK.waiting!.pointer!.click!);
|
||||
expect(T.DESK.waiting!.swap![0]).toBeGreaterThan(T.DESK.waiting!.pointer!.click!);
|
||||
// the waiting scene picks up exactly where the button scene's camera stopped (no jump on the cut)
|
||||
const cam = (id: string) => (T.DESK[id] as unknown as { cam: { from: unknown; to: unknown } }).cam;
|
||||
expect(cam('waiting').from).toEqual(cam('button').to);
|
||||
});
|
||||
test('the console plan panel shows the real plan (desktop.ts serves `console plan apple-app-privacy --json`)', () => {
|
||||
test('the console plan panel shows the real plan; no drawn system dialogs or mails', () => {
|
||||
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<span class="hov">|id="sbtn"/);
|
||||
expect(html).not.toMatch(/Submit for Review<span class="hov">|id="sbtn"|id="mail"/);
|
||||
});
|
||||
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'");
|
||||
for (const f of T.DESK_FRAMES) expect(src, f).toMatch(new RegExp(`shot\\([a-z.]+, '${f}'\\)|sweep\\('${f}'`));
|
||||
for (const s of T.DESK_SWEEPS) expect(src, s).toContain(`sweep('${s}'`);
|
||||
// the picture shows only files from out/desktop, never a drawn stand-in
|
||||
for (const f of T.DESK_FRAMES) expect(src, f).toMatch(new RegExp(`shot\\([a-z.]+, '${f}'\\)`));
|
||||
const js = readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8');
|
||||
expect(js).toContain('out/desktop/${x.g.frame}.jpg');
|
||||
expect(js).toContain('out/desktop/${f.file}.jpg');
|
||||
expect(js).toContain('out/desktop/${g.frame}.jpg');
|
||||
});
|
||||
test.if(captured)('the captured frames are present: 1280x800 @2x JPEGs, the sweeps one frame per hovered day', () => {
|
||||
const boxes = JSON.parse(readFileSync(join(DESK_DIR, 'boxes.json'), 'utf8')) as Record<string, unknown> & { source: string; sweeps: Record<string, { file: string; px: number; day: string }[]> };
|
||||
test.if(captured)('the captured frames are present: 1280x800 @2x JPEGs, every ring and pointer target captured', () => {
|
||||
const boxes = JSON.parse(readFileSync(join(DESK_DIR, 'boxes.json'), 'utf8')) as Record<string, unknown> & { source: string };
|
||||
expect(boxes.source).toContain('VITE_FIXTURES=1');
|
||||
const files = [...T.DESK_FRAMES];
|
||||
for (const s of T.DESK_SWEEPS) {
|
||||
const f = boxes.sweeps[s]!;
|
||||
expect(f.length, s).toBeGreaterThanOrEqual(8);
|
||||
expect(new Set(f.map(x => x.day)).size, s).toBe(f.length);
|
||||
for (let i = 1; i < f.length; i++) expect(f[i]!.px).toBeGreaterThan(f[i - 1]!.px);
|
||||
files.push(...f.map(x => x.file));
|
||||
}
|
||||
const sizes = new Set<number>();
|
||||
for (const f of files) {
|
||||
for (const f of T.DESK_FRAMES) {
|
||||
const p = join(DESK_DIR, `${f}.jpg`);
|
||||
expect(existsSync(p), p).toBe(true);
|
||||
const buf = readFileSync(p);
|
||||
expect(jpegSize(buf), f).toEqual({ w: 2560, h: 1600 });
|
||||
sizes.add(buf.length);
|
||||
expect(jpegSize(readFileSync(p)), f).toEqual({ w: 2560, h: 1600 });
|
||||
}
|
||||
for (const id of T.DESK_SCENES) {
|
||||
for (const c of T.DESK[id]!.rings) expect(boxes[c.target], c.target).toBeDefined();
|
||||
if (T.DESK[id]!.pointer) expect(boxes[T.DESK[id]!.pointer!.target], id).toBeDefined();
|
||||
}
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// App Store / Google Play marks from simple-icons (CC0 data)
|
||||
// apps/desktop/src/ui/store-marks the same two store marks for the desktop app (the other desktop icons come from
|
||||
// @phosphor-icons/react)
|
||||
// media/teaser/icons Phosphor icons for the teaser video (the mouse cursor, the inbox, finding and fix icons)
|
||||
// media/teaser/icons Phosphor icons for the teaser video (the mouse cursor, the finding and fix icons, the terminal, send)
|
||||
// templates/web the two store marks for the landing page `storebound web init --landing` writes
|
||||
// Both show them as CSS masks (site/style.css .ic-*, desktop styles.css .store-mark), so they take the text colour in
|
||||
// both themes and nothing is loaded from outside. The store marks only name the store next to its name; they are
|
||||
|
|
@ -17,7 +17,7 @@ import { join } from 'node:path';
|
|||
export const SITE_ICONS = ['caret-right', 'play', 'user', 'squares-four', 'briefcase'] as const;
|
||||
export const STORE_MARKS = ['appstore', 'googleplay'] as const;
|
||||
/** Teaser icons: [Phosphor weight, icon name], vendored as <name>-<weight>.svg (the package's own file name). */
|
||||
export const TEASER_ICONS = [['fill', 'navigation-arrow'], ['bold', 'check'], ['fill', 'x-circle'], ['fill', 'check-circle'], ['bold', 'tray'],
|
||||
export const TEASER_ICONS = [['fill', 'navigation-arrow'], ['bold', 'check'], ['fill', 'check-circle'],
|
||||
['fill', 'warning-octagon'], ['fill', 'paper-plane-tilt'], ['bold', 'terminal-window']] as const;
|
||||
|
||||
const root = new URL('..', import.meta.url).pathname;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue