// Teaser timeline (v4): the single source of truth for the picture (teaser.js) and the sound (music.js). // One idea per scene, 6-7 s each, one slow camera move per scene, soft dissolves between scenes (no whips, no rapid // cuts). Every scene has one plain-English caption at the top that is fully on screen for at least 4 s; the lower part // shows the real product: CLI output and desktop-app frames. // // 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` // rejections `storebound scan` of tests/fixtures/scan/teaser and, after the fixes, of teaser-fixed // The store text is examples/demo-game/store/shots/copy.json, the screenshots its real renders (render.ts copies // them), the desktop frames the app in fixture mode (desktop.ts). The plain reasons under the findings and the // callouts are annotations, set apart from the terminal text. 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 in seconds. Each holds 6-7 s; the next one dissolves in over DISSOLVE s centred on the cut. */ export const SCENES = [ { id: 'hook', start: 0, end: 6 }, { id: 'init', start: 6, end: 12 }, { id: 'plan', start: 12, end: 19 }, { id: 'store', start: 19, end: 26 }, { id: 'products', start: 26, end: 33 }, { id: 'forms', start: 33, end: 40 }, { id: 'rejections', start: 40, end: 47 }, { id: 'desktop', start: 47, end: 54 }, { id: 'submit', start: 54, end: 60 }, ]; export const DURATION = SCENES.at(-1).end; export const DISSOLVE = 0.6; export const scene = id => SCENES.find(s => s.id === id); /** * One slow camera move per scene, over the whole scene: from -> to, eased in and out. * z = zoom, x / y = pan in px (applied to the scene's content, never to the caption). */ 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 } }, rejections: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: -12 } }, desktop: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, // the push happens inside the app window submit: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: 0 } }, }; /** Captions: one per scene, at the top. *word* = highlighted. Lines are set as written. */ 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: 'rejections', lines: ['It catches rejections *before Apple does.*'], hl: 'green' }, { id: 'desktop', lines: ['Both stores, one view. *Ready for review.*'] }, { id: 'submit', lines: ['You press *Submit.* That\'s it.'] }, ]; /** Caption fade: in over CAP_IN from CAP_DELAY after the scene starts; out over CAP_OUT, ending CAP_END before it ends. */ export const CAP_DELAY = 0.15, CAP_IN = 0.4, CAP_OUT = 0.4, CAP_END = 0.1; /** When each caption is fully on screen (tests: at least 4 s). */ 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 }; }); } // ---------- 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.6, text: '2 store consoles' }, { at: 1.25, text: 'dozens of forms' }, { at: 1.9, text: '7 screenshot sizes × 14 languages' }, { at: 2.6, 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.5; export const MAIL = { at: 3.3, from: 'App Store Connect', date: '9:41 AM', subject: "There's an issue with your Prism Puddle submission", guideline: 'Guideline 2.1 · Information Needed' }; // ---------- 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.5, INIT_CPS = 13, INIT_OUT_AT = 2.5, INIT_STEP = 0.22; /** 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/', ]; // ---------- 3 plan: the agent prompt, `storebound release plan` ---------- export const PROMPT = 'Get my app ready for both stores.'; export const PROMPT_AT = 0.3, PROMPT_CPS = 26; export const PLAN_CMD = 'storebound release plan 1.0.0'; export const PLAN_CMD_AT = 1.9, PLAN_AT = 2.3, PLAN_STEP = 0.2; /** 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 = 25; export const PLAN_PILL = `*${PLAN_STEPS}* steps · both stores · in fix order`; // ---------- 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 }, ]; export const COPY_AT = 0.5, COPY_STEP = 0.5; /** 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' }, ]; export const SHOT_LANGS = COPY.map(c => c.lang); export const SHOTS_AT = 0.9, SHOTS_STEP = 0.38; 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 PRODUCT_LINES = [ { at: 0.4, text: '$ storebound play products --dry', cmd: true }, { at: 0.9, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' }, { at: 1.2, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' }, { at: 1.5, text: '[play] products checked (dry run, nothing written)' }, { at: 2.2, text: '$ storebound catalog diff', cmd: true }, { at: 2.7, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' }, { at: 3.0, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' }, { at: 3.3, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' }, { at: 3.6, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' }, { at: 4.0, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' }, ]; export const PRODUCTS_PILL = { at: 4.5, 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). */ 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.5, DECL_STEP = 0.4, ASK_AT = 2.3; /** Play Data safety form rows the detected answers fill (Play's own 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 = 2.9, FORM_STEP = 0.2; /** Data safety CSV rows `storebound check` builds from the config. */ export const DECL_TOTAL = 764; export const FORMS_PILL = { at: 4.5, text: 'Data safety answers' }; // ---------- 7 rejections: three findings with a plain reason each, then fixed ---------- export const SCAN_CMD = 'storebound scan'; export const RISKS_LINE = 'Rejection risks: 2 blocker, 3 high, 0 medium, 0 low'; /** Three of the five findings (real text); why = a plain-English annotation, not CLI output. */ export const FINDINGS = [ { at: 1.1, 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.9, 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: 2.7, 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.' }, ]; /** Each finding turns green (fixed), then the re-scan of the fixed copy (tests/fixtures/scan/teaser-fixed). */ export const FIX_AT = [4.0, 4.35, 4.7]; export const RESCAN_AT = 5.2; export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low'; // ---------- 8 desktop: the release timeline (both stores), then Store readiness: no blockers ---------- /** Real frames (desktop.ts). The camera pushes from the timeline to the readiness card; the frames dissolve between. */ export const DESKTOP = { frames: ['releases', 'ready'], swap: [3.1, 3.8], cam: { from: { f: [0.56, 0.42], z: 1.0 }, to: { f: [0.8, 0.4], z: 1.22 } }, callouts: [ { at: 1.1, until: 3.0, target: 'timeline', text: 'App Store + Google Play, one track' }, { at: 4.3, until: 7, target: 'readyEmpty', text: 'Validate: 0 blockers on both', green: true }, ], }; // ---------- 9 submit + end card ---------- export const SUBMIT = { hover: 1.5, logoAt: 3.0, logoIn: 0.8 }; export const URL = 'storebound.dev'; export const TAGLINE = 'Open source · CLI · Desktop app'; // ---------- 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). */ 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' })), ...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' })), ...FINDINGS.map(f => ({ t: at('rejections') + f.at, kind: 'low' })), ...FIX_AT.map((t, i) => ({ t: at('rejections') + t, kind: 'good', step: i })), ...DESKTOP.callouts.map(c => ({ t: at('desktop') + c.at, kind: 'soft' })), ]; } /** 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); return out; }