diff --git a/media/teaser/README.md b/media/teaser/README.md
index c58bcb8..3345e06 100644
--- a/media/teaser/README.md
+++ b/media/teaser/README.md
@@ -16,7 +16,7 @@ Chrome and encoded with ffmpeg; the music and sound design are synthesized in th
|---|---|---|---|
| 0-3 | the demo game's own screens rush past, a push in | Your game is done. | drives from frame 0: kick, bass, arpeggio |
| 3-7 | real store work lands one card per beat: App Privacy answers and steps, the Data safety CSV (768 rows), screenshot sizes, price sheets | Then come the stores. | tension: B minor pulse, kicks, a thud per card |
-| 7-11 | the common rejections storebound catches, as real scan finding cards landing one per beat (2.1(a) demo account, 5.1.1(v) account deletion, 5.1.2(i) ATT, ITMS-91064 privacy manifest), every card inside 6% safe margins; at 9.5 s our own REJECTED stamp hits the stack; the picture darkens into the cut | Rejected. Again. | a low boom, a thud per card, a hit on the stamp |
+| 7-11 | the common rejections storebound catches, as real scan finding cards landing one per beat (2.1(a) "No demo account", 5.1.1(v) account deletion, 5.1.2(i) ATT, ITMS-91064 privacy manifest), every card inside 6% safe margins; at 9.5 s our own REJECTED stamp hits the stack; the picture darkens into the cut | Rejected. Again. | a low boom, a thud per card, a hit on the stamp |
| 11-13.5 | one request typed to the AI agent, sent on the beat | So my AI did it. | a short drop: an impact, half-time beat, key clicks, a swell |
| 13.5-16.5 | `apple build --dry`, `play build --dry`: build 52, versionCode 52 | Builds. | the drop |
| 16.5-19.5 | the demo game's renders: 7 store sizes x 4 languages fill in | Screenshots, every size. | + hats |
diff --git a/media/teaser/index.html b/media/teaser/index.html
index 1cb5168..6278cc5 100644
--- a/media/teaser/index.html
+++ b/media/teaser/index.html
@@ -47,12 +47,12 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
#capband { left: 0; right: 0; top: 0; height: 260px; z-index: 45; pointer-events: none; opacity: 0;
background: linear-gradient(rgba(7,11,22,.88) 0%, rgba(7,11,22,.65) 60%, rgba(7,11,22,0) 100%); }
#cap { z-index: 50; left: 80px; right: 80px; top: 64px; text-align: center; font-family: var(--display); font-weight: 750;
- font-size: 88px; letter-spacing: -0.03em; line-height: 1.1; color: #fff; white-space: nowrap; text-shadow: 0 4px 30px rgba(0,0,0,.55); opacity: 0; }
+ font-size: 88px; letter-spacing: -0.03em; line-height: 1.2; color: #fff; white-space: nowrap; text-shadow: 0 4px 30px rgba(0,0,0,.55); opacity: 0; }
#cap .hl { color: var(--accent); } #cap.red .hl { color: var(--red); } #cap.green .hl { color: var(--green); }
#edgeband { left: 0; right: 0; bottom: 0; height: 300px; z-index: 45; pointer-events: none; opacity: 0;
background: linear-gradient(rgba(7,11,22,0) 0%, rgba(7,11,22,.75) 55%, rgba(7,11,22,.92) 100%); }
#edge { z-index: 50; left: 96px; bottom: 70px; font-family: var(--display); font-weight: 850; font-size: 108px; letter-spacing: -0.035em;
- line-height: 1; color: #fff; white-space: nowrap; text-shadow: 0 6px 30px rgba(0,0,0,.6); opacity: 0; }
+ line-height: 1.2; color: #fff; white-space: nowrap; text-shadow: 0 6px 30px rgba(0,0,0,.6); opacity: 0; }
/* ---------- shared: cards, terminal ---------- */
.card { position: absolute; background: var(--surface); border: 2px solid var(--line); border-radius: 24px; overflow: hidden;
@@ -60,9 +60,9 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
.card .ch { display: flex; align-items: center; gap: 14px; padding: 18px 26px; border-bottom: 2px solid var(--line);
font: 700 26px/1.2 var(--mono); color: #fff; background: rgba(255,255,255,.02); white-space: nowrap; }
.card .ch .dots { display: flex; gap: 9px; margin-right: 8px; } .card .ch .dots i { width: 14px; height: 14px; border-radius: 50%; background: #3a4255; display: block; }
-.card .ch .sub { margin-left: auto; font: 600 22px/1 var(--text); color: var(--muted); }
+.card .ch .sub { margin-left: auto; font: 600 22px/1.2 var(--text); color: var(--muted); }
.term { font-family: var(--mono); color: var(--ink-2); }
-.term .ln { display: block; white-space: pre; overflow: hidden; text-overflow: ellipsis; opacity: 0; }
+.term .ln { display: block; white-space: pre-wrap; overflow-wrap: anywhere; opacity: 0; }
.ps { color: var(--green); font-weight: 700; } .cmd { color: #fff; font-weight: 700; }
.caret { display: inline-block; width: .5em; height: 1.05em; background: #e5e7eb; vertical-align: -0.18em; margin-left: 4px; }
.tag { color: var(--dim); } .okk { color: var(--green); font-weight: 800; } .bad { color: var(--red); font-weight: 800; }
@@ -76,7 +76,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
.pcard { opacity: 0; transform-origin: 50% 50%; }
.pcard .body { position: relative; height: 330px; overflow: hidden; }
.pcard .rows { position: absolute; left: 0; right: 0; top: 0; padding: 14px 26px; font: 500 21px/1.6 var(--mono); color: var(--ink-2); }
-.pcard .rows div { white-space: pre; overflow: hidden; text-overflow: ellipsis; }
+.pcard .rows div { white-space: pre-wrap; overflow-wrap: anywhere; }
.pcard .rows .okk { font-weight: 800; }
.pcard .shade { position: absolute; inset: 0; background: #04060c; opacity: 0; pointer-events: none; }
.pcard .grid { position: absolute; inset: 16px 22px; }
@@ -98,7 +98,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
/* ---------- 4 ask: the request ---------- */
#prompt { left: 260px; top: 470px; width: 1400px; padding: 30px 38px; display: flex; flex-direction: column; gap: 16px;
background: rgba(21,31,54,.92); border: 2px solid var(--accent-2); border-radius: 26px; box-shadow: 0 30px 90px rgba(0,0,0,.6), 0 0 0 8px rgba(91,92,240,.12); opacity: 0; }
-#prompt .who { display: flex; align-items: center; gap: 12px; font: 800 22px/1 var(--text); color: var(--accent-ink); text-transform: uppercase; letter-spacing: .1em; }
+#prompt .who { display: flex; align-items: center; gap: 12px; font: 800 22px/1.2 var(--text); color: var(--accent-ink); text-transform: uppercase; letter-spacing: .1em; }
#prompt .txt { font: 700 50px/1.2 var(--display); color: #fff; letter-spacing: -0.015em; white-space: nowrap; }
#prompt .sent { position: absolute; right: 30px; bottom: 30px; width: 58px; height: 58px; border-radius: 50%; background: var(--accent-2);
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 28px; opacity: .35; }
@@ -138,13 +138,14 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
/* ---------- 6 scan ---------- */
#scanterm { left: 120px; top: 290px; width: 1680px; }
#scanterm .body { padding: 20px 32px 26px; font-size: 27px; line-height: 1.5; }
-.fd { position: relative; display: flex; align-items: center; gap: 16px; margin: 8px 0; padding: 10px 18px; border-radius: 14px; white-space: nowrap; opacity: 0; }
+.fd { position: relative; display: flex; align-items: center; gap: 16px; margin: 6px 0; padding: 8px 18px; border-radius: 14px; opacity: 0; }
+.fd .tx { min-width: 0; overflow-wrap: anywhere; }
.fd .tint { position: absolute; inset: 0; border-radius: 14px; }
.fd .tint.b { background: var(--red-bg); border: 2px solid rgba(255,90,95,.4); }
.fd .tint.g { background: var(--green-bg); border: 2px solid rgba(61,220,132,.55); opacity: 0; }
.fd > *:not(.tint) { position: relative; }
.fd .pi { font-size: 30px; }
-.fd .tx { overflow: hidden; text-overflow: ellipsis; color: #ffd6d7; font-weight: 600; }
+.fd .tx { color: #ffd6d7; font-weight: 600; }
.fd .sev { font-weight: 800; color: #fff; }
/* ---------- desktop window ---------- */
@@ -170,11 +171,11 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
#brand { left: 0; right: 0; top: 150px; text-align: center; }
#brand > * { opacity: 0; }
#brand .mark { width: 200px; height: 200px; display: block; margin: 0 auto; filter: drop-shadow(0 30px 70px rgba(91,92,240,.6)); }
-#brand .word { margin-top: 20px; font: 900 120px/1 var(--display); letter-spacing: -0.05em; color: #fff; }
+#brand .word { margin-top: 20px; font: 900 120px/1.15 var(--display); letter-spacing: -0.05em; color: #fff; }
#brand .cmdl { display: inline-block; margin-top: 44px; padding: 26px 48px; min-width: 1040px; text-align: left; border-radius: 22px;
background: #0b1222; border: 2px solid var(--line-2); box-shadow: 0 30px 80px rgba(0,0,0,.5); font: 800 76px/1.1 var(--mono); color: #fff; white-space: pre; }
#brand .cmdl .caret { width: .55em; height: 1em; vertical-align: -0.12em; margin-left: 6px; }
-#brand .url { margin-top: 40px; font: 750 56px/1 var(--display); color: var(--accent); letter-spacing: -0.01em; }
+#brand .url { margin-top: 40px; font: 750 56px/1.2 var(--display); color: var(--accent); letter-spacing: -0.01em; }
diff --git a/media/teaser/render.ts b/media/teaser/render.ts
index 85c739b..179e0e0 100644
--- a/media/teaser/render.ts
+++ b/media/teaser/render.ts
@@ -235,6 +235,9 @@ async function animatic(base: string, dir: string): Promise {
// the reject card at every frame of its shot (safe margins, tests/teaser.test.ts)
const rj = S.scene('reject'), rejectLayout: unknown[] = [];
for (let i = 0; i < Math.round((rj.end - rj.start) * FPS); i++) rejectLayout.push(await page.evaluate(`teaser.rejectLayout(${rj.start + i / FPS})`));
+ // text overflow at every frame of the whole teaser: each text node fits its element and its card, every card in frame
+ const textFrames = Math.round(DURATION * FPS);
+ const textProblems = await page.evaluate(`(() => { const out = []; for (let i = 0; i < ${textFrames}; i++) out.push(...teaser.textCheck(i / ${FPS})); return out; })()`) as unknown[];
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
// sound
const wav = await page.evaluate(`import('./music.js').then(m => m.renderWav())`) as { base64: string };
@@ -263,7 +266,7 @@ async function animatic(base: string, dir: string): Promise {
return `${i + 1}. ${s.start.toFixed(1)}-${s.end.toFixed(1)} s (${(s.end - s.start).toFixed(1)} s) **${s.id}**: ${S.SHOT_NOTES[s.id]}${c.length ? ` · caption "${c.map((x: any) => x.text).join('", "')}" (${c.map((x: any) => `${x.from.toFixed(2)}-${x.to.toFixed(2)} s`).join(', ')})` : ''}`;
}), ''];
writeFileSync(join(dir, 'shots.md'), md.join('\n'));
- writeFileSync(join(OUT, 'animatic-checks.json'), JSON.stringify({ duration: DURATION, fps: FPS, frame: FORMAT, endLayout: layout, rejectLayout }, null, 2));
+ writeFileSync(join(OUT, 'animatic-checks.json'), JSON.stringify({ duration: DURATION, fps: FPS, frame: FORMAT, endLayout: layout, rejectLayout, textFrames, textProblems }, null, 2));
log(`animatic: ${dir} (draft.mp4, contact-sheet.png, shots.md); layout -> out/animatic-checks.json`);
} finally { await browser.close(); }
}
diff --git a/media/teaser/script.js b/media/teaser/script.js
index 4c6a392..487e425 100644
--- a/media/teaser/script.js
+++ b/media/teaser/script.js
@@ -65,9 +65,9 @@ export const CAMERA = {
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, 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.12, x: -60, y: 0 } },
- builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 40, y: -40 } },
+ builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 0, y: -40 } },
shots: { from: { z: 0.98, x: 0, y: 0 }, to: { z: 1.08, x: 0, y: 0 } },
- products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 60, y: -50 } },
+ products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 0, y: -50 } },
forms: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.07, x: 30, 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.08, x: 0, y: -20 } },
@@ -190,13 +190,13 @@ export const PILE_TITLES = { privacy: 'App Privacy · answer sheet', steps: 'App
/** Real `storebound scan` findings: guideline + one plain line, the scan's own header below in small type.
* head = the finding line as printed (tests/teaser.test.ts checks it in the fixture's scan output). */
export const REJECTS = [
- { fixture: 'teaser', rule: 'Guideline 2.1(a)', title: 'No demo account for the reviewer',
+ { fixture: 'teaser', rule: 'Guideline 2.1(a)', title: 'No demo account',
head: 'BLOCKER 2.1(a) · App Store · Sign-in is required but there is no demo account for App Review' },
{ fixture: 'teaser', rule: 'Guideline 5.1.1(v)', title: 'No way to delete the account',
head: 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found' },
{ fixture: 'teaser', rule: 'Guideline 5.1.2(i)', title: 'Tracking without asking (ATT)',
head: 'HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)' },
- { fixture: 'teaser-manifest', rule: 'ITMS-91064', title: 'Privacy manifest refused at upload',
+ { fixture: 'teaser-manifest', rule: 'ITMS-91064', title: 'Manifest refused',
head: 'BLOCKER Privacy manifest (ITMS-91064) · App Store · Planner/PrivacyInfo.xcprivacy: NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (Apple refuses the build: ITMS-91064)' },
];
/** The cards land one per beat (s into the shot; scale `land.from` -> 1 in `land.d` s, no shake). The REJECTED stamp
diff --git a/media/teaser/teaser.js b/media/teaser/teaser.js
index 2ad4cf4..cc20586 100644
--- a/media/teaser/teaser.js
+++ b/media/teaser/teaser.js
@@ -162,8 +162,6 @@ function pile(t) {
set(el, 'opacity', String(clamp(p * 3)));
const e = eoc(p);
set(el, 'transform', `translateY(${lerp(-70, 0, e).toFixed(1)}px) rotate(${lerp(c.rot * 2.2, c.rot, e).toFixed(2)}deg) scale(${lerp(1.22, 1, e).toFixed(4)})`);
- // the rows keep streaming: there is always more
- if (rows) set(rows, 'transform', `translateY(${(-Math.min(maxScroll, Math.max(0, r - c.at) * 34)).toFixed(1)}px)`);
// older cards sink into the shadow of the new ones
const above = PILE.slice(i + 1).reduce((k, x) => k + eoc(prog(r, x.at - LEAD, 0.2)), 0);
set(shade, 'opacity', String(Math.min(0.6, above * 0.16).toFixed(3)));
@@ -433,6 +431,34 @@ function rejectLayout(t) {
const box = el => { const b = el.getBoundingClientRect(); return { x: b.x * k, y: b.y * k, w: b.width * k, h: b.height * k }; };
return { t, cards: rjEls.filter(el => Number(el.style.opacity) > 0).map(box), stamp: Number(stampEl.style.opacity) > 0 ? box(stampEl) : null };
}
+/** Text overflow at time t: every visible text node must fit its element (scroll size <= client size) and lie inside
+ * its container (a card, the stamp, the prompt, else the frame). Returns the problems found (empty = all fits). */
+function textCheck(t) {
+ seek(t);
+ const k = W / stage.getBoundingClientRect().width, sr = stage.getBoundingClientRect(), bad = [];
+ const shown = el => { for (let e = el; e && e !== stage; e = e.parentElement) { const cs = getComputedStyle(e); if (cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) === 0) return false; } return true; };
+ // measure in layout space: transforms (camera, card tilts, the stamp's rotation) off while measuring
+ // first, with the transforms on: every text container (card, terminal, stamp) is inside the frame as drawn
+ for (const c of new Set([...stage.querySelectorAll('.rj, .card, #stamp, #prompt')].filter(shown))) {
+ const b = c.getBoundingClientRect();
+ if (b.width && (b.left < sr.left - 1 || b.right > sr.right + 1 || b.top < sr.top - 1 || b.bottom > sr.bottom + 1)) bad.push({ t, id: c.id || c.className, text: c.textContent.trim().slice(0, 60), why: `outside the frame ${(b.left - sr.left) | 0},${(b.top - sr.top) | 0} ${b.width | 0}x${b.height | 0}` });
+ }
+ const tf = [...stage.querySelectorAll('*')].filter(e => e.style.transform).map(e => [e, e.style.transform]);
+ for (const [e] of tf) e.style.transform = 'none';
+ const walk = document.createTreeWalker(stage, NodeFilter.SHOW_TEXT, { acceptNode: n => n.textContent.trim() ? 1 : 3 });
+ for (let n; (n = walk.nextNode());) {
+ const el = n.parentElement; if (!shown(el)) continue;
+ const r = document.createRange(); r.selectNodeContents(n); const b = r.getBoundingClientRect();
+ if (!b.width) continue;
+ const box = el.closest('[data-box], .rj, .card, #stamp, #prompt, #cap, #edge, #brand') ?? stage;
+ const c = box.getBoundingClientRect(), cs = getComputedStyle(el);
+ const text = n.textContent.trim().slice(0, 60), id = box.id || box.className || 'frame';
+ if (cs.display !== 'inline' && (el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1)) bad.push({ t, id, text, why: `scroll ${el.scrollWidth}x${el.scrollHeight} > ${el.clientWidth}x${el.clientHeight}` });
+ if (b.left < c.left - 1 || b.right > c.right + 1 || b.top < c.top - 1 || b.bottom > c.bottom + 1) bad.push({ t, id, text, why: `text box outside ${id}: ${((b.left - sr.left) * k) | 0},${((b.top - sr.top) * k) | 0} ${(b.width * k) | 0}x${(b.height * k) | 0}` });
+ }
+ for (const [e, v] of tf) e.style.transform = v;
+ return bad;
+}
/** Layout data for the animatic checks: the end card's install line, as drawn at time t. */
function layout(t) {
seek(t);
@@ -461,6 +487,11 @@ function seek(t) {
const ready = (async () => {
await document.fonts.ready;
+ // the pile's cards show only the rows that fit whole (no row cut by the card's edge)
+ for (const { rows } of pileCards) if (rows) {
+ const h = rows.parentElement.clientHeight;
+ for (const d of [...rows.children]) if (d.offsetTop + d.offsetHeight > h - 14) d.remove();
+ }
const imgs = [...document.images];
const bgs = [...document.querySelectorAll('.tile, .phone')].map(el => Object.assign(new Image(), { src: el.style.backgroundImage.replace(/^url\("?|"?\)$/g, '') }));
// a few at a time, each retried: Chrome refuses some of many parallel large decodes under load (EncodingError)
@@ -476,7 +507,7 @@ const ready = (async () => {
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, layout, rejectLayout, DURATION, FPS, W, H, captionTimes };
+window.teaser = { ready, seek, layout, rejectLayout, textCheck, DURATION, FPS, W, H, captionTimes };
if (q.has('t')) ready.then(() => seek(Number(q.get('t'))));
if (q.has('play')) ready.then(() => {
diff --git a/tests/teaser.test.ts b/tests/teaser.test.ts
index 0302a2d..46fcd30 100644
--- a/tests/teaser.test.ts
+++ b/tests/teaser.test.ts
@@ -293,6 +293,12 @@ describe('teaser: timing and format', () => {
expect(b.y + b.h, at).toBeLessThanOrEqual(T.FORMAT.h - my);
}
expect(j.rejectLayout!.at(-1)!.cards.length).toBe(T.REJECTS.length);
+ });
+ test.skipIf(!existsSync(CHECKS))('no text overflows anywhere: every text node fits its element and its card, every card in frame, at every frame (animatic layout data)', () => {
+ const j = JSON.parse(readFileSync(CHECKS, 'utf8')) as { duration: number; textFrames?: number; textProblems?: unknown[] };
+ expect(j.duration).toBe(T.DURATION);
+ expect(j.textFrames).toBe(Math.round(T.DURATION * T.FPS));
+ expect(j.textProblems?.slice(0, 10)).toEqual([]);
expect(j.rejectLayout!.some(f => f.stamp)).toBe(true);
});
test('the picture darkens with the rejection and lifts on the drop', () => {