// Teaser picture (v6): 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, 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, RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_CMD, SHOTS_PILL, SHOTS_STEP, SUBMIT, TAGLINE, TODO, URL, scene } from './script.js'; const q = new URLSearchParams(location.search); const W = FORMAT.w, H = FORMAT.h; const $ = s => document.querySelector(s); const stage = $('#stage'); stage.style.width = `${W}px`; stage.style.height = `${H}px`; // ---------- helpers ---------- const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)); const prog = (t, a, d) => (d <= 0 ? (t >= a ? 1 : 0) : clamp((t - a) / d)); const eoc = p => 1 - Math.pow(1 - p, 3); const eio = p => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2); /** The camera: a quick ease-out (quartic) that settles at 80% of the scene, so each move reads snappy, then rests. */ const ecam = p => 1 - Math.pow(1 - clamp(p / 0.8), 4); const lerp = (a, b, p) => a + (b - a) * p; const esc = s => String(s).replace(/&/g, '&').replace(//g, '>'); const set = (el, k, v) => { if (el.__s?.[k] !== v) { (el.__s ??= {})[k] = v; el.style[k] = v; } }; const html = (el, v) => { if (el.__h !== v) { el.__h = v; el.innerHTML = v; } }; /** *word* -> word (pills) */ const bold = s => esc(s).replace(/\*([^*]+)\*/g, '$1'); /** A gentle appear: opacity and a small rise. */ function appear(el, t, at, d = 0.35, rise = 14) { const p = prog(t, at, d); set(el, 'opacity', String(eoc(p))); set(el, 'transform', `translateY(${((1 - eoc(p)) * rise).toFixed(1)}px)`); return p; } function pillAt(el, t, at, base = 'translate(-50%, 0)') { const p = prog(t, at, 0.4); set(el, 'opacity', String(eoc(p))); set(el, 'transform', `${base} translateY(${((1 - eoc(p)) * 16).toFixed(1)}px)`); } // ---------- scenes: dissolves and one camera move each ---------- const sceneEls = Object.fromEntries(SCENES.map(s => [s.id, { el: $(`#s-${s.id}`), cam: $(`#s-${s.id} > .cam`) }])); function scenes(t) { SCENES.forEach((s, i) => { const { el, cam } = sceneEls[s.id]; const from = i === 0 ? -1 : s.start - DISSOLVE / 2, until = i === SCENES.length - 1 ? Infinity : s.end + DISSOLVE / 2; if (t < from || t >= until) { set(el, 'visibility', 'hidden'); set(el, 'opacity', '0'); return; } set(el, 'visibility', 'visible'); set(el, 'opacity', String(i === 0 ? 1 : eio(prog(t, from, DISSOLVE)))); const c = CAMERA[s.id]; const p = ecam(clamp((t - (s.start - DISSOLVE / 2)) / (s.end - s.start + DISSOLVE))); const z = lerp(c.from.z, c.to.z, p), x = lerp(c.from.x, c.to.x, p), y = lerp(c.from.y, c.to.y, p); set(cam, 'transform', `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${z.toFixed(5)})`); }); } const active = t => SCENES.find(s => t >= s.start && t < s.end) ?? SCENES[SCENES.length - 1]; // ---------- background: a slow drift ---------- const g1 = $('#bg .g1'), g2 = $('#bg .g2'), grid = $('#bg .grid'); function background(t) { set(g1, 'transform', `translate(${(W * 0.25 + Math.sin(t * 0.11) * 160 - 700).toFixed(1)}px, ${(H * 0.35 + Math.cos(t * 0.09) * 120 - 700).toFixed(1)}px)`); set(g2, 'transform', `translate(${(W * 0.8 + Math.cos(t * 0.1) * 160 - 550).toFixed(1)}px, ${(H * 0.75 + Math.sin(t * 0.12) * 110 - 550).toFixed(1)}px)`); set(grid, 'transform', `translate(${((t * 6) % 96).toFixed(2)}px, ${((t * 8) % 96).toFixed(2)}px)`); } // ---------- caption ---------- const capEl = $('#cap'); const CT = Object.fromEntries(captionTimes().map(c => [c.id, c])); let capId = null; function buildCaption(c) { capEl.className = c.hl ?? ''; capEl.style.fontSize = ''; capEl.innerHTML = c.lines.map(l => `${esc(l).replace(/\*([^*]+)\*/g, '$1')}`).join(''); const avail = capEl.clientWidth, base = parseFloat(getComputedStyle(capEl).fontSize); const widest = Math.max(...[...capEl.querySelectorAll('.line')].map(l => l.scrollWidth)); if (widest > avail) capEl.style.fontSize = `${Math.floor(base * avail / widest)}px`; } function captions(t) { const s = active(t), c = CAPTIONS.find(x => x.id === s.id), ct = CT[s.id]; if (!c) { set(capEl, 'opacity', '0'); return; } if (capId !== c.id) { capId = c.id; buildCaption(c); } const pin = s === SCENES[0] && t < CAP_DELAY ? 0 : eoc(prog(t, ct.at, CAP_IN)); const pout = s === SCENES.at(-1) ? 0 : prog(t, ct.to, CAP_OUT); set(capEl, 'opacity', String(pin * (1 - pout))); set(capEl, 'transform', `translateY(${((1 - pin) * 12 - pout * 8).toFixed(1)}px)`); } // keep the constants in use (they define captionTimes, imported above for the timing test) void CAP_DELAY; void CAP_END; // ---------- 1 hook ---------- const todoRows = $('#todo .rows'), mailEl = $('#mail'), appcard = $('#appcard'); $('#appcard .nm').textContent = APP_NAME; todoRows.innerHTML = TODO.map(x => `
${esc(x.text)}
`).join(''); const todoEls = [...todoRows.querySelectorAll('.row')]; mailEl.querySelector('.from').textContent = MAIL.from; mailEl.querySelector('.date').textContent = MAIL.date; mailEl.querySelector('.subj').textContent = MAIL.subject; mailEl.querySelector('.prev').innerHTML = `Hello, Thank you for submitting your app… ${esc(MAIL.guideline)}`; function hook(t) { const s = scene('hook'); if (t > s.end + 1) return; const r = t - s.start; appear(appcard, r, 0.05, 0.4, 20); appear($('#todo'), r, 0.2, 0.4, 20); todoEls.forEach((el, i) => appear(el, r, TODO[i].at, 0.4, 10)); const p = prog(r, MAIL.at, MAIL_IN); set(mailEl, 'opacity', String(eoc(p))); set(mailEl, 'transform', `translateY(${((1 - eoc(p)) * 50).toFixed(1)}px)`); } // ---------- 2 init ---------- const folderEl = $('#folder'), filesEl = $('#folder .files'), emptyEl = $('#folder .empty'), initBody = $('#initterm .body'); $('#folder .fname').textContent = `${FOLDER}/`; /** The folder list: one entry per file `init` creates (from the real output lines). */ const FILES = INIT_LINES.map((l, i) => ({ i, m: l.match(/^\[init\] create (.+)$/) })).filter(x => x.m).map(x => ({ i: x.i, path: x.m[1] })); filesEl.innerHTML = FILES.map(f => { const k = f.path.lastIndexOf('/'); return `
${k >= 0 ? `${esc(f.path.slice(0, k + 1))}` : ''}${esc(f.path.slice(k + 1))}
`; }).join(''); const fileEls = [...filesEl.querySelectorAll('.f')]; const tagged = l => esc(l).replace(/^(\[\w+\])/, '$1'); initBody.innerHTML = `${INIT_LINES.map(l => `${tagged(l).replace(/ (create) /, ' $1 ')}`).join('')}`; const initLns = [...initBody.querySelectorAll('.ln')]; function init(t) { const s = scene('init'); if (t < s.start - 1 || t > s.end + 1) return; const r = t - s.start; const n = clamp(Math.floor((r - INIT_TYPE_AT) * INIT_CPS), 0, INIT_CMD.length); const caret = r < INIT_OUT_AT && (n < INIT_CMD.length || Math.floor(r * 2.2) % 2 === 0); html(initLns[0], `~/${esc(FOLDER)} $ ${esc(INIT_CMD.slice(0, n))}${caret ? '' : ''}`); set(initLns[0], 'opacity', '1'); initLns.slice(1).forEach((el, i) => appear(el, r, INIT_OUT_AT + 0.2 + i * INIT_STEP, 0.25, 6)); fileEls.forEach((el, k) => { const p = prog(r, INIT_OUT_AT + 0.2 + FILES[k].i * INIT_STEP, 0.3); set(el, 'opacity', String(eoc(p))); set(el, 'transform', `translateX(${((1 - eoc(p)) * -16).toFixed(1)}px)`); }); set(emptyEl, 'opacity', String(1 - prog(r, INIT_OUT_AT + 0.1, 0.3))); void folderEl; } // ---------- 3 plan ---------- const promptEl = $('#prompt'), promptTxt = $('#prompt .txt'), planTerm = $('#planterm'), planBody = $('#planterm .body'), planPill = $('#plan-pill'); const planLine = l => { if (/^\S/.test(l)) return `${esc(l)}`; if (/^ (both stores|App Store|Google Play)/.test(l)) return `${esc(l)}`; return esc(l).replace(/^(\s+\d+\.) (CONFIG |STORE |BLOCKER)/, (m, a, b) => `${a} ${b}`); }; planBody.innerHTML = `${PLAN_LINES.map(l => `${planLine(l)}`).join('')}`; const planLns = [...planBody.querySelectorAll('.ln')]; planPill.innerHTML = bold(PLAN_PILL); function plan(t) { const s = scene('plan'); if (t < s.start - 1 || t > s.end + 1) return; const r = t - s.start; appear(promptEl, r, 0.05, 0.35, 10); const n = clamp(Math.floor((r - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length); html(promptTxt, `${esc(PROMPT.slice(0, n))}${n < PROMPT.length || r < PLAN_CMD_AT ? '' : ''}`); appear(planTerm, r, PLAN_CMD_AT - 0.35, 0.4, 16); html(planLns[0], `$ ${esc(PLAN_CMD)}`); appear(planLns[0], r, PLAN_CMD_AT, 0.25, 4); planLns.slice(1).forEach((el, i) => appear(el, r, PLAN_AT + i * PLAN_STEP, 0.25, 6)); pillAt(planPill, r, PLAN_AT + PLAN_LINES.length * PLAN_STEP + 0.15); } // ---------- 4 store ---------- const copyRows = $('#copy .rows'), shotsEl = $('#shots'), storePill = $('#store-pill'), shotCmd = $('#shotcmd'); $('#copy .fname').textContent = COPY_FILE; copyRows.innerHTML = COPY.map(c => `
${esc(c.lang)}${esc(c.title.join(' '))}
`).join(''); const copyEls = [...copyRows.querySelectorAll('.row')]; storePill.innerHTML = bold(SHOTS_PILL); shotCmd.querySelector('.cmd').textContent = SHOTS_CMD; /** The grid: one column per size (portrait sizes by aspect, the feature graphic last), one row per language. */ const DIMS = { ios69: [1320, 2868], ios65: [1284, 2778], ipad13: [2064, 2752], play: [1080, 1920], tab7: [1080, 1920], tab10: [1440, 2560], feature: [1024, 500] }; const shotTiles = [], shotLabels = [], langLabels = []; { const LAB = 50, LANGW = 52, GAP = 14, AREA_W = 1020, AREA_H = 700; const RH = (AREA_H - LAB - GAP * (SHOT_LANGS.length - 1)) / SHOT_LANGS.length; const FW = 280, FH = FW * DIMS.feature[1] / DIMS.feature[0]; const widths = SHOT_SIZES.map(sz => (sz.id === 'feature' ? FW : RH * DIMS[sz.id][0] / DIMS[sz.id][1])); const total = LANGW + widths.reduce((a, b) => a + b, 0) + GAP * SHOT_SIZES.length; let x = (AREA_W - total) / 2 + LANGW + GAP; SHOT_LANGS.forEach((lang, j) => { const el = document.createElement('div'); el.className = 'lang'; el.textContent = lang; Object.assign(el.style, { left: `${(AREA_W - total) / 2}px`, top: `${LAB + j * (RH + GAP) + RH / 2 - 10}px` }); shotsEl.appendChild(el); langLabels.push(el); }); SHOT_SIZES.forEach((sz, i) => { const w = widths[i], h = sz.id === 'feature' ? FH : RH; const lab = document.createElement('div'); lab.className = 'lab'; lab.textContent = sz.label; Object.assign(lab.style, { left: `${x + w / 2}px`, top: '0px', transform: 'translateX(-50%)' }); shotsEl.appendChild(lab); shotLabels.push({ el: lab, col: i }); SHOT_LANGS.forEach((lang, j) => { const el = document.createElement('div'); el.className = 'tile'; Object.assign(el.style, { left: `${x}px`, top: `${LAB + j * (RH + GAP) + (RH - h) / 2}px`, width: `${w}px`, height: `${h}px`, backgroundImage: `url(out/assets/shots/${lang}-${sz.id}.jpg)`, borderRadius: sz.id === 'feature' ? '6px' : '9px' }); shotsEl.appendChild(el); shotTiles.push({ el, col: i, row: j }); }); x += w + GAP; }); } function store(t) { const s = scene('store'); if (t < s.start - 1 || t > s.end + 1) return; const r = t - s.start; appear($('#copy'), r, 0.0, 0.4, 14); copyEls.forEach((el, i) => appear(el, r, COPY_AT + i * COPY_STEP, 0.4, 10)); langLabels.forEach((el, j) => appear(el, r, SHOTS_AT - 0.2 + j * 0.05, 0.3, 0)); shotLabels.forEach(({ el, col }) => { const p = prog(r, SHOTS_AT + col * SHOTS_STEP, 0.35); set(el, 'opacity', String(eoc(p))); }); for (const { el, col, row } of shotTiles) { const p = prog(r, SHOTS_AT + col * SHOTS_STEP + row * 0.06, 0.4); set(el, 'opacity', String(eoc(p))); set(el, 'transform', `scale(${lerp(0.9, 1, eoc(p)).toFixed(4)})`); } const done = SHOTS_AT + SHOT_SIZES.length * SHOTS_STEP + 0.2; pillAt(storePill, r, done); pillAt(shotCmd, r, done + 0.2, 'translateX(-50%)'); } // ---------- 5 products ---------- const prodBody = $('#prodterm .body'), prodPill = $('#prod-pill'); const prodLine = l => (l.cmd ? `$ ${esc(l.text.replace(/^\$ /, ''))}` : tagged(l.text).replace(/^ok {4}/, 'ok ').replace(/(\d+ regions?|\d+ region prices)/, '$1') .replace(/(0 mismatch\(es\))/, '$1')); prodBody.innerHTML = PRODUCT_LINES.map((l, i) => `${i && l.cmd ? ' ' : ''}${prodLine(l)}`).join(''); const prodLns = [...prodBody.querySelectorAll('.ln')].filter(el => el.style.opacity !== '1'); prodPill.innerHTML = bold(PRODUCTS_PILL.text); function products(t) { const s = scene('products'); if (t < s.start - 1 || t > s.end + 1) return; const r = t - s.start; appear($('#prodterm'), r, 0.0, 0.4, 14); prodLns.forEach((el, i) => appear(el, r, PRODUCT_LINES[i].at, 0.3, 6)); pillAt(prodPill, r, PRODUCTS_PILL.at); } // ---------- 6 forms ---------- const propBody = $('#prop .body'), formBody = $('#form .body'), formsPill = $('#forms-pill'); propBody.innerHTML = DECL_LINES.map(d => `detected ${esc(d.what)}:(${esc(d.where)})${esc(d.value)}`).join('') + `ask owner ${esc(DECL_ASK.replace(/^ask owner /, '').split(':')[0])}:${esc(DECL_ASK.replace(/^ask owner [^:]+:/, ''))}` + 'Only you know this one: storebound asks.'; const propLns = [...propBody.querySelectorAll('.ln')], askGlow = $('#prop .ask .glow'), askNote = $('#prop .askn'); formBody.innerHTML = DATA_SAFETY_ROWS.map(r => `
${esc(r)}detected
`).join(''); const formRows = [...formBody.querySelectorAll('.row')]; function forms(t) { const s = scene('forms'); if (t < s.start - 1 || t > s.end + 1) return; const r = t - s.start; appear($('#prop'), r, 0.0, 0.4, 14); appear($('#form'), r, 0.25, 0.4, 14); propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT + i * DECL_STEP : ASK_AT, 0.3, 6)); const g = prog(r, ASK_AT + 0.15, 0.4); set(askGlow, 'opacity', String(eoc(g))); appear(askNote, r, ASK_AT + 0.4, 0.4, 6); formRows.forEach((el, i) => { const p = prog(r, FORM_AT + i * FORM_STEP, 0.25); set(el.querySelector('.fill'), 'opacity', String(eoc(p))); set(el.querySelector('.pi'), 'opacity', String(eoc(p))); set(el.querySelector('.box'), 'borderColor', p > 0 ? 'var(--green)' : 'var(--line-2)'); set(el.querySelector('.tg'), 'opacity', String(p)); set(el, 'color', p > 0.5 ? '#fff' : 'var(--ink-2)'); }); const np = prog(r, FORMS_PILL.at, 0.8); html(formsPill, `${Math.round(DECL_TOTAL * eoc(np))} ${esc(FORMS_PILL.text)}`); pillAt(formsPill, r, FORMS_PILL.at); } // ---------- 7 rejections ---------- const scanBody = $('#scanterm .body'); scanBody.innerHTML = `$ ${esc(SCAN_CMD)}` + `${esc(RISKS_LINE).replace(/(2 blocker, 3 high)/, '$1')}` + FINDINGS.map((f, i) => `
${i + 1}.${f.sev}${esc(f.guideline)}${esc(f.title)}
${esc(f.why)}at ${esc(f.where)}FIXED
`).join('') + `$ ${esc(SCAN_CMD)} (after the fixes)` + `${esc(RESCAN_LINE)}`; const scanLns = [...scanBody.querySelectorAll(':scope > .ln')], fdEls = [...scanBody.querySelectorAll('.fd')]; function rejections(t) { const s = scene('rejections'); if (t < s.start - 1 || t > s.end + 1) return; const r = t - s.start; appear($('#scanterm'), r, 0.0, 0.4, 14); appear(scanLns[0], r, 0.2, 0.25, 4); appear(scanLns[1], r, 0.45, 0.3, 6); fdEls.forEach((el, i) => { appear(el, r, FINDINGS[i].at, 0.4, 10); const f = prog(r, FIX_AT[i], 0.45); set(el.querySelector('.tint.g'), 'opacity', String(eoc(f))); set(el.querySelector('.tint.b'), 'opacity', String(1 - eoc(f))); set(el.querySelector('.fixed'), 'opacity', String(eoc(f))); set(el.querySelector('.why .pi'), 'color', f > 0.5 ? 'var(--green)' : 'var(--red)'); }); appear(scanLns[2], r, RESCAN_AT, 0.3, 6); 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 ---------- 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 }; WIN.h = VIEW.h + WIN.bar; const S0 = VIEW.w / 1280; const sweepOf = g => { if (!g.sweep) return null; const f = BOXES.sweeps?.[g.sweep.seq]; if (!f?.length) throw new Error(`boxes.json: no "${g.sweep.seq}" sweep (bun media/teaser/desktop.ts)`); return f; }; 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 = `
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') }; }); function deskView(dk, r) { const s = scene(dk.id), len = s.end - s.start; const p = ecam(clamp((r + DISSOLVE / 2) / (len + DISSOLVE))); const { from, to } = dk.d.cam; const S = S0 * lerp(from.z, to.z, p), fx = lerp(from.f[0], to.f[0], p), fy = lerp(from.f[1], to.f[1], p); const ox = clamp(VIEW.w / 2 - fx * 1280 * S, VIEW.w - 1280 * S, 0), oy = clamp(VIEW.h / 2 - fy * 800 * S, VIEW.h - 800 * S, 0); return { S, ox, oy }; } const stageBox = (b, c) => ({ x: WIN.x + c.ox + b.x * 1280 * c.S, y: WIN.y + WIN.bar + c.oy + b.y * 800 * c.S, w: b.w * 1280 * c.S, h: b.h * 800 * c.S }); /** A hover sweep at r: the pointer glides from the first hovered day's position to the last one's (ease in-out); the * frame is the last day whose position the pointer has reached, as on the real chart. */ function sweepAt(sw, f, r) { const p = eio(prog(r, sw.at, sw.d)); const px = lerp(f[0].px, f.at(-1).px, p), py = lerp(f[0].py, f.at(-1).py, p); let k = -1; if (r >= sw.at) { k = 0; while (k + 1 < f.length && f[k + 1].px <= px + 1e-9) k++; } return { px, py, k }; } 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; 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; if (dk.grpEls[1]) set(dk.grpEls[1], 'opacity', String(sw)); // the pointer: fades in just before its group's sweep, rests after it, fades with the dissolve to the next group let cur = null; dk.groups.forEach((x, gi) => { if (!x.sweep) return; const st = sweepAt(x.g.sweep, x.sweep, r); dk.imgs[gi].forEach((im, i) => set(im, 'opacity', i === 0 || i === st.k + 1 ? '1' : '0')); const vis = gi === 0 && d.swap ? 1 - prog(r, d.swap[0] - 0.2, 0.25) : 1; const op = prog(r, x.g.sweep.at - 0.35, 0.3) * vis; if (op > 0 && (!cur || op > cur.op)) cur = { ...st, op }; }); if (dk.cur) { set(dk.cur, 'opacity', String(cur ? cur.op : 0)); if (cur) { const b = stageBox({ x: cur.px, y: cur.py, w: 0, h: 0 }, c); set(dk.cur, 'transform', `translate(${(b.x - 10).toFixed(1)}px, ${(b.y - 10).toFixed(1)}px)`); } } d.callouts.forEach((co, i) => { const b = stageBox(BOXES[co.target], c); const op = eoc(prog(r, co.at, 0.4)) * (1 - prog(r, co.until - 0.3, 0.3)); const ring = dk.rings[i], call = dk.callouts[i]; const x0 = Math.max(b.x - 10, WIN.x + 8), y0 = Math.max(b.y - 10, WIN.y + WIN.bar + 6); const x1 = Math.min(b.x + b.w + 10, WIN.x + WIN.w - 8), y1 = Math.min(b.y + b.h + 10, WIN.y + WIN.h - 8); set(ring, 'opacity', String(op)); set(ring, 'left', `${x0.toFixed(1)}px`); set(ring, 'top', `${y0.toFixed(1)}px`); 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; set(call, 'opacity', String(op)); set(call, 'left', `${cx.toFixed(1)}px`); set(call, 'top', `${(below ? y1 + 24 : y0 - 24).toFixed(1)}px`); set(call, 'transform', `translate(-50%, ${below ? '0' : '-100%'}) scale(${lerp(0.92, 1, eoc(prog(r, co.at, 0.4))).toFixed(3)})`); }); } const desktop = t => desks.forEach(dk => deskScene(dk, t)); // ---------- 9 submit + end card ---------- const sbtn = $('#sbtn'), shov = $('#sbtn .hov'), brand = $('#brand'), cursorEl = $('#cursor'); 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)`); [...brand.children].forEach((el, i) => { const q2 = prog(r, SUBMIT.logoAt + 0.2 + i * 0.18, SUBMIT.logoIn); set(el, 'opacity', String(eoc(q2))); set(el, 'transform', `translateY(${((1 - eoc(q2)) * 18).toFixed(1)}px)`); }); } function seek(t) { scenes(t); background(t); hook(t); init(t); plan(t); store(t); products(t); forms(t); rejections(t); desktop(t); submit(t); captions(t); } 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}`); }))); seek(0); })(); window.teaser = { ready, seek, DURATION, FPS, W, H, captionTimes }; if (q.has('t')) ready.then(() => seek(Number(q.get('t')))); if (q.has('play')) ready.then(() => { const t0 = performance.now(); const loop = () => { seek(((performance.now() - t0) / 1000) % DURATION); requestAnimationFrame(loop); }; loop(); });