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,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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue