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

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