325 lines
24 KiB
JavaScript
325 lines
24 KiB
JavaScript
// 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/<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 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;
|
|
}
|