-
Submit for Review
storebound
@@ -273,7 +278,6 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
-
diff --git a/media/teaser/music.js b/media/teaser/music.js
index e9b8c52..0105c09 100644
--- a/media/teaser/music.js
+++ b/media/teaser/music.js
@@ -17,9 +17,11 @@ function rng(seed) { // mulberry32
const CH = {
Bm: [47, [0, 3, 7, 14]], G: [43, [0, 4, 7, 14]], D: [50, [0, 4, 7, 11]], A: [45, [0, 4, 7, 14]], Em: [52, [0, 3, 7, 10]], Dadd9: [50, [0, 4, 7, 14]],
};
-const SCENE_CHORD = { hook: 'Bm', init: 'G', plan: 'D', store: 'A', products: 'Bm', forms: 'G', rejections: 'Em', portfolio: 'G', charts: 'D', catalog: 'Bm', desktop: 'A', submit: 'D' };
+const SCENE_CHORD = { hook: 'Bm', init: 'G', plan: 'D', store: 'A', products: 'Bm', forms: 'G', consoles: 'A', rejections: 'Em', portfolio: 'G', charts: 'D', catalog: 'Bm', resubmit: 'G', submit: 'D' };
const ARP = [0, 1, 2, 3, 2, 1, 2, 3];
-const STEP = 0.5; // one soft note every half second
+/** One soft note every half second: every scene is a whole number of half seconds long (tests/teaser.test.ts), so each
+ * cut lands on a note of the arpeggio. */
+export const STEP = 0.5;
export async function renderMusic() {
const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(DURATION * SR) + SR * 2, sampleRate: SR });
diff --git a/media/teaser/render.ts b/media/teaser/render.ts
index d516e1f..b783ee7 100644
--- a/media/teaser/render.ts
+++ b/media/teaser/render.ts
@@ -21,8 +21,8 @@ const args = process.argv.slice(2);
const opt = (k: string): string | undefined => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : undefined; };
/** 16:9 only. */
const FORMAT = { w: 1920, h: 1080 };
-/** The web version: 1280x720 H.264, two-pass to about this many bytes (about 860 kbps for 55 s, as v5 had for 42 s). */
-const WEB_BYTES = 6e6;
+/** The web version: 1280x720 H.264, two-pass to about this many bytes (about 770 kbps video for 62 s, as v6 had for 55.5 s). */
+const WEB_BYTES = 6.7e6;
const CAPTURE_FPS = 60;
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [teaser] ${m}`);
diff --git a/media/teaser/script.js b/media/teaser/script.js
index f60ca6b..ae1e5ec 100644
--- a/media/teaser/script.js
+++ b/media/teaser/script.js
@@ -1,5 +1,5 @@
-// 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
+// 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.
@@ -12,9 +12,11 @@
// 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). The plain reasons under the findings and the
+// 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.
export const FPS = 30;
@@ -22,7 +24,7 @@ export const FPS = 30;
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]];
+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 }]; }, []);
export const DURATION = SCENES.at(-1).end;
export const DISSOLVE = 0.4;
@@ -39,13 +41,15 @@ export const CAMERA = {
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 } },
- 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 } },
+ 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 } },
};
/** Captions: one per scene, at the top. *word* = highlighted. Lines are set as written. */
@@ -56,11 +60,12 @@ export const CAPTIONS = [
{ 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: 'desktop', lines: ['Both stores, one view. *Ready for review.*'] },
+ { id: 'resubmit', lines: ['Rejected? Fix it, *resubmit in one click.*'] },
{ 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. */
@@ -187,7 +192,31 @@ export const FORM_AT = 1.9, FORM_STEP = 0.14;
export const DECL_TOTAL = 768;
export const FORMS_PILL = { at: 3.0, text: 'Data safety answers' };
-// ---------- 7 rejections: three findings with a plain reason each, then fixed ----------
+// ---------- 7 consoles: the console plan of a form without an API, then the app's plan panel ----------
+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 },
+];
+/** 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];
+
+// ---------- 8 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. */
@@ -204,13 +233,22 @@ 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 ----------
+// ---------- 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: one per scene, on a box captured from the app (boxes.json). */
+ * 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). */
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 }],
+ },
portfolio: {
groups: [{ frame: 'portfolio-apps' }],
cam: { from: { f: [0.5, 0.42], z: 1.0 }, to: { f: [0.6, 0.6], z: 1.14 } },
@@ -227,15 +265,28 @@ export const DESK = {
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 } },
+ // 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.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 },
+ { 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 },
},
};
export const DESK_SCENES = Object.keys(DESK);
@@ -243,8 +294,9 @@ export const DESK_SCENES = Object.keys(DESK);
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 };
+// ---------- 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';
@@ -265,9 +317,10 @@ export function ticks() {
...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 => ({ t: at(id) + c.at, kind: 'soft' }))),
+ ...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' }))),
];
}
/** Seconds at which each typed character lands (soft key clicks). */
@@ -275,5 +328,6 @@ 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);
return out;
}
diff --git a/media/teaser/teaser.js b/media/teaser/teaser.js
index 17d4108..ee63dc8 100644
--- a/media/teaser/teaser.js
+++ b/media/teaser/teaser.js
@@ -1,6 +1,7 @@
-// Teaser picture (v6): every element is set from the time t alone (window.teaser.seek(t)), so frames render
+// Teaser picture (v7): every element is set from the time t alone (window.teaser.seek(t)), so frames render
// deterministically, in any order. 16:9 only (1920x1080). URL: &t=12.5 holds a frame, &play=1 previews in real time.
-import { APP_NAME, ASK_AT, CAMERA, CAP_DELAY, CAP_END, CAP_IN, CAP_OUT, CAPTIONS, captionTimes, COPY, COPY_AT, COPY_FILE, COPY_STEP,
+import { APP_NAME, ASK_AT, CAMERA, CAP_DELAY, CAP_END, CAP_IN, CAP_OUT, CAPTIONS, captionTimes, CONSOLE_CMD, CONSOLE_CPS, CONSOLE_LINES,
+ CONSOLE_TYPE_AT, CONSOLES_PILL, CONSOLES_SWAP, COPY, COPY_AT, COPY_FILE, COPY_STEP,
DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESK, DESK_SCENES, DISSOLVE, DURATION, FINDINGS, FIX_AT, FOLDER,
FORM_AT, FORM_STEP, FORMAT, FORMS_PILL, FPS, INIT_CMD, INIT_CPS, INIT_LINES, INIT_OUT_AT, INIT_STEP, INIT_TYPE_AT,
MAIL, MAIL_IN, PLAN_AT, PLAN_CMD, PLAN_CMD_AT, PLAN_LINES, PLAN_PILL, PLAN_STEP, PRODUCT_LINES, PRODUCTS_PILL, PROMPT, PROMPT_AT, PROMPT_CPS,
@@ -264,7 +265,34 @@ function forms(t) {
pillAt(formsPill, r, FORMS_PILL.at);
}
-// ---------- 7 rejections ----------
+// ---------- 7 consoles: the console plan in the terminal, then the app's plan panel (DESK.consoles) ----------
+const cpBody = $('#cpterm .body'), cpPill = $('#cp-pill'), cpTerm = $('#cpterm');
+const cpLine = l => {
+ if (l.final) return `
${esc(l.text.replace(/: Publish$/, ':'))} Publish`;
+ if (l.head) return `
${esc(l.text)}`;
+ if (/^Never press:/.test(l.text)) return esc(l.text).replace(/^(Never press:)/, '
$1');
+ return esc(l.text).replace(/^(\s+\d+\.)/, '
$1').replace(/^(\s+)\[ok\]/, '$1
[ok]')
+ .replace(/^(\s+)\[ask\]/, '$1
[ask]').replace(/(\[click: [^\]]+\])/, '
$1');
+};
+cpBody.innerHTML = `
${CONSOLE_LINES.map(l => `
${cpLine(l)}`).join('')}`;
+const cpLns = [...cpBody.querySelectorAll('.ln')], cpGlow = $('#cpterm .fin .glow');
+cpPill.innerHTML = bold(CONSOLES_PILL.text);
+function consoles(t) {
+ const s = scene('consoles'); if (t < s.start - 1 || t > s.end + 1) return;
+ const r = t - s.start;
+ const out = 1 - eio(prog(r, CONSOLES_SWAP[0], CONSOLES_SWAP[1]));
+ appear(cpTerm, r, 0.0, 0.35, 14);
+ set(cpTerm, 'opacity', String(eoc(prog(r, 0, 0.35)) * out));
+ const n = clamp(Math.floor((r - CONSOLE_TYPE_AT) * CONSOLE_CPS), 0, CONSOLE_CMD.length);
+ html(cpLns[0], `
$ ${esc(CONSOLE_CMD.slice(0, n))}${n < CONSOLE_CMD.length ? '
' : ''}`);
+ set(cpLns[0], 'opacity', '1');
+ cpLns.slice(1).forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at, 0.25, 6));
+ set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.15, 0.4))));
+ pillAt(cpPill, r, CONSOLES_PILL.at);
+ set(cpPill, 'opacity', String(eoc(prog(r, CONSOLES_PILL.at, 0.4)) * out));
+}
+
+// ---------- 8 rejections ----------
const scanBody = $('#scanterm .body');
scanBody.innerHTML = `
$ ${esc(SCAN_CMD)}`
+ `
${esc(RISKS_LINE).replace(/(2 blocker, 3 high)/, '$1')}`
@@ -292,7 +320,7 @@ function rejections(t) {
appear(scanLns[3], r, RESCAN_AT + 0.35, 0.3, 6);
}
-// ---------- 8-11 desktop: real frames in a window, one push per scene, one callout, hover sweeps on the charts ----------
+// ---------- 7, 9-13 desktop: real frames in a window, one push per scene, callouts, hover sweeps, a resting pointer ----------
const BOXES = await fetch('out/desktop/boxes.json').then(r => { if (!r.ok) throw new Error('out/desktop/boxes.json missing (bun media/teaser/desktop.ts)'); return r.json(); });
const WIN = { x: 370, y: 236, w: 1180, bar: 40 };
const VIEW = { w: WIN.w, h: WIN.w * 800 / 1280 };
@@ -307,14 +335,17 @@ const sweepOf = g => {
const desks = DESK_SCENES.map(id => {
const d = DESK[id], cam = $(`#s-${id} > .cam`);
const groups = d.groups.map(g => ({ g, sweep: sweepOf(g) }));
- cam.innerHTML = `
+ // the window lives in the scene's .dhost (the consoles scene has its terminal next to it), else in the camera
+ const host = cam.querySelector('.dhost') ?? cam.appendChild(Object.assign(document.createElement('div'), { className: 'dhost' }));
+ host.innerHTML = `
storebound
${groups.map((x, gi) => `

${(x.sweep ?? []).map(f => `

`).join('')}
`).join('')}
${d.callouts.map(c => `
${esc(c.text)}
`).join('')}
- ${groups.some(x => x.sweep) ? '
' : ''}`;
- const grpEls = [...cam.querySelectorAll('.grp')];
- return { id, d, groups, cam2: cam.querySelector('.cam2'), grpEls, imgs: grpEls.map(g => [...g.querySelectorAll('img')]),
- rings: [...cam.querySelectorAll('.ring')], callouts: [...cam.querySelectorAll('.callout')], cur: cam.querySelector('.dcur') };
+ ${groups.some(x => x.sweep) || d.pointer ? '
' : ''}`;
+ if (d.pointer && !BOXES[d.pointer.target]) throw new Error(`boxes.json: no "${d.pointer.target}" (bun media/teaser/desktop.ts)`);
+ const grpEls = [...host.querySelectorAll('.grp')];
+ return { id, d, host, groups, cam2: host.querySelector('.cam2'), grpEls, imgs: grpEls.map(g => [...g.querySelectorAll('img')]),
+ rings: [...host.querySelectorAll('.ring')], callouts: [...host.querySelectorAll('.callout')], cur: host.querySelector('.dcur') };
});
function deskView(dk, r) {
const s = scene(dk.id), len = s.end - s.start;
@@ -337,6 +368,10 @@ function sweepAt(sw, f, r) {
function deskScene(dk, t) {
const s = scene(dk.id); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start, d = dk.d;
+ // the window fades in (show) and out (hide), with its callouts and pointer
+ const shown = (d.show ? eio(prog(r, d.show[0], d.show[1])) : 1) * (d.hide ? 1 - eio(prog(r, d.hide[0], d.hide[1])) : 1);
+ set(dk.host, 'opacity', String(shown));
+ set(dk.host, 'visibility', shown > 0 ? 'visible' : 'hidden');
const c = deskView(dk, r);
set(dk.cam2, 'transform', `translate(${c.ox.toFixed(2)}px, ${c.oy.toFixed(2)}px) scale(${c.S.toFixed(5)})`);
const sw = d.swap ? eio(prog(r, d.swap[0], d.swap[1] - d.swap[0])) : 0;
@@ -351,6 +386,14 @@ function deskScene(dk, t) {
const op = prog(r, x.g.sweep.at - 0.35, 0.3) * vis;
if (op > 0 && (!cur || op > cur.op)) cur = { ...st, op };
});
+ // a resting pointer: glides in from below right onto its target and stays there (it never clicks)
+ if (d.pointer) {
+ const pt = d.pointer, b = BOXES[pt.target];
+ const tx = b.x + b.w * 0.62, ty = b.y + b.h * 0.64;
+ const p = eio(prog(r, pt.at, pt.d));
+ const op = prog(r, pt.at - 0.25, 0.25) * (pt.until ? 1 - prog(r, pt.until - 0.1, 0.25) : 1);
+ if (op > 0) cur = { px: lerp(Math.min(tx + 0.16, 0.97), tx, p), py: lerp(Math.min(ty + 0.2, 0.97), ty, p), op };
+ }
if (dk.cur) {
set(dk.cur, 'opacity', String(cur ? cur.op : 0));
if (cur) {
@@ -369,7 +412,7 @@ function deskScene(dk, t) {
set(ring, 'width', `${(x1 - x0).toFixed(1)}px`); set(ring, 'height', `${(y1 - y0).toFixed(1)}px`);
const cw = call.offsetWidth;
const cx = clamp((x0 + x1) / 2, 60 + cw / 2, W - 60 - cw / 2);
- const below = y1 + 90 < WIN.y + WIN.h;
+ const below = !co.above && y1 + 90 < WIN.y + WIN.h;
set(call, 'opacity', String(op));
set(call, 'left', `${cx.toFixed(1)}px`);
set(call, 'top', `${(below ? y1 + 24 : y0 - 24).toFixed(1)}px`);
@@ -378,25 +421,14 @@ function deskScene(dk, t) {
}
const desktop = t => desks.forEach(dk => deskScene(dk, t));
-// ---------- 9 submit + end card ----------
-const sbtn = $('#sbtn'), shov = $('#sbtn .hov'), brand = $('#brand'), cursorEl = $('#cursor');
+// ---------- 13 submit: the end card (the app window is DESK.submit; it fades out as the card comes in) ----------
+const brand = $('#brand');
brand.querySelector('.url').textContent = URL;
brand.querySelector('.plat').textContent = TAGLINE;
function submit(t) {
const s = scene('submit');
const r = t - s.start;
- if (t < s.start - 1) { set(cursorEl, 'opacity', '0'); return; }
- const hov = prog(r, SUBMIT.hover, 0.25);
- const out = prog(r, SUBMIT.logoAt, 0.6);
- set(sbtn, 'opacity', String(eoc(prog(r, 0.0, 0.4)) * (1 - eio(out))));
- set(sbtn, 'transform', `translate(-50%, 0) scale(${(lerp(0.96, 1, eoc(prog(r, 0, 0.5))) * (1 + 0.02 * hov)).toFixed(4)})`);
- set(shov, 'opacity', String(hov));
- // the cursor glides in and rests on the button: it never clicks
- const btn = sbtn.getBoundingClientRect();
- const tx = btn.left + btn.width * 0.64, ty = btn.top + btn.height * 0.66;
- const p = eio(prog(r, 0.3, SUBMIT.hover - 0.3));
- set(cursorEl, 'opacity', String(prog(r, 0.3, 0.3) * (1 - eio(out))));
- set(cursorEl, 'transform', `translate(${(lerp(W * 0.86, tx, p) - 10).toFixed(1)}px, ${(lerp(H * 0.95, ty, p) - 10).toFixed(1)}px)`);
+ if (t < s.start - 1) return;
[...brand.children].forEach((el, i) => {
const q2 = prog(r, SUBMIT.logoAt + 0.2 + i * 0.18, SUBMIT.logoIn);
set(el, 'opacity', String(eoc(q2)));
@@ -413,6 +445,7 @@ function seek(t) {
store(t);
products(t);
forms(t);
+ consoles(t);
rejections(t);
desktop(t);
submit(t);
@@ -423,7 +456,17 @@ const ready = (async () => {
await document.fonts.ready;
const imgs = [...document.images];
const bgs = [...document.querySelectorAll('.tile')].map(el => Object.assign(new Image(), { src: el.style.backgroundImage.replace(/^url\("?|"?\)$/g, '') }));
- await Promise.all([...imgs, ...bgs].map(im => im.decode().catch(() => { throw new Error(`image failed: ${im.src}`); })));
+ // a few at a time, each retried: Chrome refuses some of ~90 parallel 2560x1600 decodes under load (EncodingError)
+ const all = [...imgs, ...bgs];
+ const decode = async im => {
+ for (let i = 0; ; i++) {
+ try { await im.decode(); return; } catch (e) {
+ if (i >= 3) throw new Error(`image failed: ${im.src} (${e})`);
+ await new Promise(r => setTimeout(r, 200 * (i + 1)));
+ }
+ }
+ };
+ for (let i = 0; i < all.length; i += 8) await Promise.all(all.slice(i, i + 8).map(decode));
seek(0);
})();
window.teaser = { ready, seek, DURATION, FPS, W, H, captionTimes };
diff --git a/tests/teaser.test.ts b/tests/teaser.test.ts
index 81219db..ea76189 100644
--- a/tests/teaser.test.ts
+++ b/tests/teaser.test.ts
@@ -1,6 +1,6 @@
-// The teaser video (media/teaser, v6): every CLI line it shows is real output of the command it names, every desktop
-// frame is a real capture of the app (desktop.ts), each scene holds 4-5 s, each caption is fully on screen for at least
-// 3 s, the whole cut is 50-60 s, and it is 16:9 only.
+// The teaser video (media/teaser, v7): every CLI line it shows is real output of the command it names, every desktop
+// frame is a real capture of the app (desktop.ts), each scene holds 4-5 s (a whole number of beats), each caption is
+// fully on screen for at least 3 s, the whole cut is 60-65 s, and it is 16:9 only.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@@ -25,9 +25,13 @@ interface Teaser {
COPY_FILE: string; COPY: { lang: string; title: string[] }[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; SHOTS_PILL: string;
PRODUCT_LINES: { text: string; cmd?: boolean }[]; PRODUCTS_PILL: { text: string };
DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string; DECL_TOTAL: number;
+ CONSOLE_CMD: string; CONSOLE_LINES: { text: string; head?: boolean; final?: boolean }[]; CONSOLE_STEPS: number; CONSOLE_ANSWERS: number;
+ CONSOLES_PILL: { text: string }; CONSOLES_SWAP: number[];
SCAN_CMD: string; RISKS_LINE: string; RESCAN_LINE: string;
FINDINGS: { sev: string; guideline: string; title: string; where: string; why: string }[];
- DESK: Record
;
+ DESK: Record;
+ SUBMIT: { logoAt: number };
DESK_SCENES: string[]; DESK_FRAMES: string[]; DESK_SWEEPS: string[];
}
const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser;
@@ -100,6 +104,27 @@ describe('teaser: every CLI line is real output', () => {
} finally { writeFileSync(cfg, src); }
});
+ test('consoles: `storebound console plan apple-app-privacy` (offline): steps, answer sheet, never-press, the owner\'s Publish', () => {
+ expect(T.CONSOLE_CMD).toBe('storebound console plan apple-app-privacy');
+ const r = run(T.CONSOLE_CMD.split(' ').slice(1), dir);
+ expect(r.code).toBe(0);
+ const out = lines(r.out);
+ for (const l of T.CONSOLE_LINES) expect(out, l.text).toContain(l.text);
+ // the lines keep the order of the real plan
+ const idx = T.CONSOLE_LINES.map(l => out.indexOf(l.text));
+ expect([...idx].sort((a, b) => a - b)).toEqual(idx);
+ // the pill's counts: the numbered steps and the answer-sheet rows of the whole plan
+ const steps = out.slice(out.indexOf('Steps:'), out.indexOf('Answer sheet:')).filter(l => /^\s+\d+\. /.test(l)).length;
+ const answers = out.filter(l => /^ {2}\[(ok|ask|check|agree)\] /.test(l)).length;
+ expect(steps).toBe(T.CONSOLE_STEPS);
+ expect(answers).toBe(T.CONSOLE_ANSWERS);
+ expect(T.CONSOLES_PILL.text).toBe(`*${steps}* steps · *${answers}* answers · stops before *Publish*`);
+ // the final button stays the owner's, and the never-press list holds it
+ const fin = T.CONSOLE_LINES.find(l => l.final)!;
+ expect(fin.text).toBe("Final button (the owner's): Publish");
+ expect(T.CONSOLE_LINES.find(l => l.text.startsWith('Never press:'))!.text).toContain('"Publish"');
+ });
+
test('forms: the Data safety answer count of `storebound check`', () => {
expect(run(['check'], dir).out).toContain(`data safety: ${T.DECL_TOTAL} CSV rows`);
});
@@ -159,16 +184,20 @@ describe('teaser: timing and format', () => {
expect(render).not.toMatch(/1080\s*x\s*1920|w: 1080, h: 1920|vertical/i);
expect(render).toContain('scale=1280:720');
});
- test('scenes are contiguous, 4-5 s each, 50-60 s in total', () => {
+ test('scenes are contiguous, 4-5 s each (whole half seconds: each cut on a beat of music.js), 60-65 s in total', () => {
expect(T.SCENES[0]!.start).toBe(0);
for (let i = 1; i < T.SCENES.length; i++) expect(T.SCENES[i]!.start).toBe(T.SCENES[i - 1]!.end);
for (const s of T.SCENES) {
expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(4);
expect(s.end - s.start, s.id).toBeLessThanOrEqual(5);
+ expect(Number.isInteger(s.start * 2), s.id).toBe(true);
}
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
- expect(T.DURATION).toBeGreaterThanOrEqual(50);
- expect(T.DURATION).toBeLessThanOrEqual(60);
+ expect(T.DURATION).toBeGreaterThanOrEqual(60);
+ expect(T.DURATION).toBeLessThanOrEqual(65);
+ const music = readFileSync(join(ROOT, 'media/teaser/music.js'), 'utf8');
+ expect(music).toContain('export const STEP = 0.5;');
+ for (const s of T.SCENES) expect(music, s.id).toMatch(new RegExp(`SCENE_CHORD = \\{[^}]*\\b${s.id}: '`));
});
test('one caption per scene, fully on screen for at least 3 s, inside its scene', () => {
const c = T.captionTimes();
@@ -186,7 +215,8 @@ describe('teaser: timing and format', () => {
});
});
-// The desktop sequence: four scenes of the real app (fixture mode), captured by media/teaser/desktop.ts. The frames are
+// The desktop sequence: the real app (fixture mode) in the consoles scene and the last five, captured by
+// media/teaser/desktop.ts. The frames are
// gitignored output; the file checks run wherever they were captured (render.ts captures them before every render).
const DESK_DIR = join(ROOT, 'media/teaser/out/desktop');
const captured = existsSync(join(DESK_DIR, 'boxes.json'));
@@ -203,15 +233,46 @@ function jpegSize(buf: Buffer): { w: number; h: number } | null {
}
describe('teaser: the desktop scenes are real captures of the app', () => {
- test('four desktop scenes in a row before the submit card, the last one is v5\'s (timeline, then 0 blockers)', () => {
- expect(T.DESK_SCENES).toEqual(['portfolio', 'charts', 'catalog', 'desktop']);
+ test('the app in the consoles scene and the last five: the plan panel, portfolio, charts, prices, resubmit, submit', () => {
+ expect(T.DESK_SCENES).toEqual(['consoles', 'portfolio', 'charts', 'catalog', 'resubmit', 'submit']);
const ids = T.SCENES.map(s => s.id);
- expect(ids.slice(-5)).toEqual([...T.DESK_SCENES, 'submit']);
- expect(T.DESK.desktop!.groups.map(g => g.frame)).toEqual(['releases', 'ready']);
- expect(T.CAPTIONS.find(c => c.id === 'desktop')!.lines.join(' ').replace(/\*/g, '')).toBe('Both stores, one view. Ready for review.');
- for (const id of T.DESK_SCENES) expect(T.DESK[id]!.callouts.length, id).toBe(1 + (id === 'desktop' ? 1 : 0));
+ expect(ids).toEqual(['hook', 'init', 'plan', 'store', 'products', 'forms', 'consoles', 'rejections', 'portfolio', 'charts', 'catalog', 'resubmit', 'submit']);
+ const cap = (id: string) => T.CAPTIONS.find(c => c.id === id)!.lines.join(' ').replace(/\*/g, '');
+ expect(cap('consoles')).toBe('No API? Your AI fills the console. It stops at Publish.');
+ expect(cap('resubmit')).toBe('Rejected? Fix it, resubmit in one click.');
+ expect(cap('submit')).toBe('You press Submit. That\'s it.');
+ // the terminal gives way to the app's plan panel, which comes in as the terminal goes
+ expect(T.DESK.consoles!.groups.map(g => g.frame)).toEqual(['console-plan']);
+ expect(T.DESK.consoles!.show).toEqual(T.CONSOLES_SWAP);
+ expect(T.DESK.resubmit!.groups.map(g => g.frame)).toEqual(['resubmit']);
+ // submit: the dry-run plan with Continue, then waiting for review; the window fades out as the end card comes in
+ expect(T.DESK.submit!.groups.map(g => g.frame)).toEqual(['submit-plan', 'submit-waiting']);
+ expect(T.DESK.submit!.hide![0]).toBe(T.SUBMIT.logoAt);
+ expect(T.DESK.submit!.pointer!.target).toBe('oaContinue');
+ expect(T.DESK.resubmit!.pointer!.target).toBe('resubmitBtn');
+ // the v6 desktop scene (timeline, readiness) is gone: v7 is 62 s with resubmit and submit
+ expect(ids).not.toContain('desktop');
+ for (const id of T.DESK_SCENES) {
+ const s = T.SCENES.find(x => x.id === id)!;
+ expect(T.DESK[id]!.callouts.length, id).toBeGreaterThanOrEqual(1);
+ expect(T.DESK[id]!.callouts.length, id).toBeLessThanOrEqual(2);
+ // callouts follow one another, never two at once, and each holds long enough to read
+ const c = T.DESK[id]!.callouts;
+ for (let i = 0; i < c.length; i++) {
+ expect(Math.min(c[i]!.until, s.end - s.start) - c[i]!.at, `${id} ${c[i]!.text}`).toBeGreaterThanOrEqual(0.9);
+ if (i) expect(c[i]!.at, id).toBeGreaterThanOrEqual(c[i - 1]!.until);
+ }
+ }
expect(T.DESK_SWEEPS).toEqual(['growth', 'health']);
});
+ test('the console plan panel shows the real plan (desktop.ts serves `console plan apple-app-privacy --json`)', () => {
+ const src = readFileSync(join(ROOT, 'media/teaser/desktop.ts'), 'utf8');
+ expect(src).toContain("realConsolePlan('apple-app-privacy')");
+ expect(src).toContain("['console', 'plan', form, '--json']");
+ // never a drawn macOS dialog: the submit scene shows the app's own plan panel before the system dialog
+ const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
+ expect(html).not.toMatch(/Submit for Review|id="sbtn"/);
+ });
test('every frame a scene names is one desktop.ts captures from the app (no mock frames)', () => {
const src = readFileSync(join(ROOT, 'media/teaser/desktop.ts'), 'utf8');
expect(src).toContain("VITE_FIXTURES: '1'");
@@ -244,5 +305,6 @@ describe('teaser: the desktop scenes are real captures of the app', () => {
// every sweep frame differs from the others (the tooltip moved): no copied frames
expect(sizes.size).toBeGreaterThan(files.length * 0.9);
for (const id of T.DESK_SCENES) for (const c of T.DESK[id]!.callouts) expect(boxes[c.target], c.target).toBeDefined();
+ for (const id of T.DESK_SCENES) if (T.DESK[id]!.pointer) expect(boxes[T.DESK[id]!.pointer!.target], id).toBeDefined();
});
});