teaser: no text overflows anywhere: teaser.textCheck(t) checks every visible text node (scroll size within its element, its box inside its card, every card in frame as drawn), render.ts --animatic runs it at every frame of the whole teaser into animatic-checks.json and tests/teaser.test.ts requires zero problems; reject titles shortened ('No demo account', 'Manifest refused'); fixes it found: terminal and pile rows wrap instead of being cut with an ellipsis, the scan findings wrap, the pile shows only the rows that fit whole (no streaming past the edge), caption, edge word, labels and end card line heights fit their glyphs, builds and products push straight in so the terminal stays in frame

This commit is contained in:
CRTkafa 2026-10-14 05:47:40 +08:00
parent 20a764b3ee
commit 49557b4c74
6 changed files with 60 additions and 19 deletions

View file

@ -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 |

View file

@ -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; }
</style>
</head>
<body>

View file

@ -235,6 +235,9 @@ async function animatic(base: string, dir: string): Promise<void> {
// 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<void> {
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(); }
}

View file

@ -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

View file

@ -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(() => {

View file

@ -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', () => {