// Teaser timeline (v6): the single source of truth for the picture (teaser.js) and the sound (music.js). // One idea per scene, 4-5 s each (about 55 s in all), one camera move per scene with a quick ease-out, short dissolves // 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. // // 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 }; /** Scene lengths in seconds (4-5 s each). The next scene dissolves in over DISSOLVE s centred on the cut. */ const LENGTHS = [['hook', 4.5], ['init', 4.5], ['plan', 5], ['store', 5], ['products', 4.5], ['forms', 5], ['rejections', 5], ['portfolio', 4.5], ['charts', 4.5], ['catalog', 4.5], ['desktop', 4.5], ['submit', 4]]; export const SCENES = LENGTHS.reduce((a, [id, len]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len }]; }, []); export const DURATION = SCENES.at(-1).end; export const DISSOLVE = 0.4; export const scene = id => SCENES.find(s => s.id === id); /** * 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). */ 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 } }, // the desktop scenes: the push happens inside the app window (DESK) portfolio: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, charts: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, catalog: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, desktop: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, submit: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: 0 } }, }; /** 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: 'portfolio', lines: ['Stop switching between two consoles.', 'Every app, both stores, *one window.*'] }, { id: 'charts', lines: ['Installs, conversion and crashes, *charted.*'] }, { id: 'catalog', lines: ['Products and prices on both stores, *side by side.*'] }, { id: 'desktop', lines: ['Both stores, one view. *Ready for review.*'] }, { id: '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.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). */ 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.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' }; // ---------- 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/', ]; // ---------- 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 = 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.3, COPY_STEP = 0.3; /** 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.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 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' }, ]; 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). */ 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.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 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 = 766; export const FORMS_PILL = { at: 3.0, 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: 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.' }, ]; /** 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 RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low'; // ---------- 8-11 desktop: the app as the one console for both stores ---------- /** Real frames of the desktop app in fixture mode (desktop.ts), one entry per scene. groups: what the window shows, in * order; a group is one frame, or a frame plus a hover sweep (the pointer glides across a chart, the crosshair and * tooltip follow it day by day: one captured frame per hovered day, pointer positions in boxes.json). swap: the * dissolve from group 1 to group 2 (seconds into the scene). cam: one push, f = focus point (fraction of the 1280x800 * frame), z = zoom. callouts: one per scene, on a box captured from the app (boxes.json). */ export const DESK = { 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' }], }, 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' }], }, // v5's desktop scene, unchanged: the release timeline, then Store readiness (validate): no blockers desktop: { groups: [{ frame: 'releases' }, { frame: 'ready' }], swap: [2.1, 2.6], cam: { from: { f: [0.56, 0.42], z: 1.0 }, to: { f: [0.8, 0.4], z: 1.22 } }, callouts: [ { at: 0.7, until: 2.0, target: 'timeline', text: 'App Store + Google Play, one track' }, { at: 2.9, until: 5, target: 'readyEmpty', text: 'Validate: 0 blockers on both', green: true }, ], }, }; export const DESK_SCENES = Object.keys(DESK); /** Every captured frame a desktop scene names (sweep frames are listed in boxes.json by desktop.ts). */ export const DESK_FRAMES = [...new Set(Object.values(DESK).flatMap(d => d.groups.map(g => g.frame)))]; export const DESK_SWEEPS = Object.values(DESK).flatMap(d => d.groups.filter(g => g.sweep).map(g => g.sweep.seq)); // ---------- 9 submit + end card ---------- export const SUBMIT = { hover: 1.0, logoAt: 2.0, logoIn: 0.6 }; 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 })), ...DESK_SCENES.flatMap(id => DESK[id].callouts.map(c => ({ t: at(id) + 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; }