// 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. // // 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 }; /** 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 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: from -> to (z = zoom, x / y = pan in px), a slow ease (story) or a quick push (montage). */ export const CAMERA = { 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: 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: '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 }, ]; /** 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), [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 game: the demo game's real screens ---------- /** examples/demo-game/store/shots/frames/en/phone/.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 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 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 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)' }, ]; /** 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 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 = ['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.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' }, ]; /** 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' }, { what: 'PSL_EMAIL', value: 'collect: account, functionality', where: 'Podfile.lock:3' }, { 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.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 = 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'; export const CONSOLE_LINES = [ { 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 }, ]; /** 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]; // ---------- 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'; /** The five findings' own header lines (after the number). */ export const FINDINGS = [ '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)', ]; 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'; // ---------- 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 = { consoles: { groups: [{ frame: 'console-plan' }], show: CONSOLES_SWAP, 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 }], }, // 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' }, }, // 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. */ export const DESK_FRAMES = [...new Set(Object.values(DESK).flatMap(d => d.groups.map(g => g.frame)))]; /** The click on Submit (absolute seconds): the music's resolve starts here. */ export const CLICK_T = scene('waiting').start + DESK.waiting.pointer.click; // ---------- 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) ---------- /** 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 [ ...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' })), ...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: '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 < PROMPT.length; i++) if (PROMPT[i] !== ' ' || i % 2) out.push(scene('ask').start + PROMPT_AT + i / PROMPT_CPS); return out; }