From 49557b4c74534a377392b36f6d18d442398b56b4 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 05:47:40 +0800 Subject: [PATCH] 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 --- media/teaser/README.md | 2 +- media/teaser/index.html | 21 +++++++++++---------- media/teaser/render.ts | 5 ++++- media/teaser/script.js | 8 ++++---- media/teaser/teaser.js | 37 ++++++++++++++++++++++++++++++++++--- tests/teaser.test.ts | 6 ++++++ 6 files changed, 60 insertions(+), 19 deletions(-) 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', () => {