storebound/cli/shots/template.html

295 lines
25 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/fonts/fonts.css">
<!--
Store screenshot template (rendered by `storebound shots render`, served by its local server).
template.html?s=<scene id>&w=<px>&h=<px>&lang=<id>[&device=tablet][&style=android][&v=<variant>]
style=android (Play 7" / 10" tablets): a plain Android tablet body (thin dark bezel, punch-hole camera) instead of the iPad look.
/job.json gives the scene (palette, frames, badge slots), the words for this language and the font families.
Layout: everything is in u = W / 100; the text block is measured first, then the phone fills the room below it.
device=landscape (Mac, Chromebook, Play tablets with shots.landscape.playTablets): u = H / 100, the copy in a column on
one side (job.side), the first frame of the scene from frames/<lang>/landscape/ on the other, in a Mac window
(frame=window) or bare with rounded corners (frame=none). Badges are not drawn in landscape.
Safe margins (lib.ts SAFE_MARGIN): text and logo keep 7 % of the width left and right and 5 % of the height at the
top; their boxes go to body.dataset.boxes -> report.json -> shots check.
-->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { overflow: hidden; background: #000; }
body { font-family: var(--bf); -webkit-font-smoothing: antialiased; color: #fff; }
#c { position: relative; overflow: hidden; width: var(--W); height: var(--H); isolation: isolate; background: var(--bg); }
/* background: sunburst, glow, vignette, sparkles */
.rays { position: absolute; left: 50%; top: var(--rayY); width: calc(var(--u) * 260); height: calc(var(--u) * 260);
transform: translate(-50%, -50%) rotate(var(--rayRot, 0deg)); z-index: -4; opacity: .55;
background: repeating-conic-gradient(from 0deg, rgba(255,255,255,.20) 0deg 7deg, rgba(255,255,255,0) 7deg 18deg);
-webkit-mask-image: radial-gradient(closest-side, #000 0%, rgba(0,0,0,.65) 35%, transparent 80%); }
.glow { position: absolute; left: 50%; top: var(--rayY); width: calc(var(--u) * 150); height: calc(var(--u) * 150); z-index: -3;
transform: translate(-50%, -50%); background: radial-gradient(closest-side, var(--glow) 0%, transparent 100%); opacity: .9; }
.vign { position: absolute; inset: 0; z-index: -2; pointer-events: none;
background: radial-gradient(120% 70% at 50% 45%, transparent 55%, rgba(0,0,0,.28) 100%); }
.spark { position: absolute; z-index: -1; width: var(--s); height: var(--s); transform: translate(-50%, -50%) rotate(var(--r));
background: #fff; opacity: var(--o);
clip-path: polygon(50% 0, 58% 42%, 100% 50%, 58% 58%, 50% 100%, 42% 58%, 0 50%, 42% 42%);
filter: drop-shadow(0 0 calc(var(--u) * .8) rgba(255,255,255,.9)); }
.dot { position: absolute; z-index: -1; width: var(--s); height: var(--s); border-radius: 50%; transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(255,255,255,.55), rgba(255,255,255,0) 70%); }
/* text block */
.top { position: absolute; left: 0; right: 0; top: calc(var(--u) * var(--top)); text-align: center; z-index: 6;
padding: 0 calc(var(--W) * .07); display: flex; flex-direction: column; align-items: center; }
.eyebrow { display: inline-flex; align-items: center; gap: calc(var(--u) * 1.6); margin-bottom: calc(var(--u) * 3);
padding: calc(var(--u) * 1.15) calc(var(--u) * 3) calc(var(--u) * 1.15) calc(var(--u) * 1.3); border-radius: 999px;
background: rgba(0,0,0,.26); box-shadow: inset 0 0 0 calc(var(--u) * .25) rgba(255,255,255,.28);
font-size: calc(var(--u) * 2.75); font-weight: 800; letter-spacing: var(--kls, .2em); text-transform: uppercase; line-height: 1.15; }
.eyebrow b { display: grid; place-items: center; width: calc(var(--u) * 5); height: calc(var(--u) * 5); border-radius: 50%;
background: linear-gradient(180deg, #fff3a8, #ffc31f 55%, #ff9500); color: #5a2300; font-weight: 900; letter-spacing: 0;
font-size: calc(var(--u) * 2.5); box-shadow: 0 calc(var(--u) * .35) 0 rgba(0,0,0,.25); }
h1 { font-family: var(--tf); font-weight: var(--tw); line-height: var(--lh); letter-spacing: var(--ls, .005em);
font-size: calc(var(--u) * var(--fs)); color: #fff;
-webkit-text-stroke: calc(var(--u) * var(--fs) * var(--sk, .085)) var(--ink); paint-order: stroke fill;
filter: drop-shadow(0 calc(var(--u) * .9) 0 var(--ink)) drop-shadow(0 calc(var(--u) * 1.6) calc(var(--u) * 2.2) rgba(0,0,0,.3)); }
h1 span { display: block; width: max-content; margin: 0 auto; white-space: nowrap; }
h1 span + span { color: #ffd23a; font-size: .86em; margin-top: .06em; }
p.sub { margin-top: calc(var(--u) * 2.6); max-width: calc(var(--u) * 80); font-size: calc(var(--u) * var(--sub));
line-height: 1.32; font-weight: 700; color: #fff; text-wrap: balance;
text-shadow: 0 calc(var(--u) * .3) calc(var(--u) * 1) rgba(0,0,0,.35); }
.logo { width: 100%; max-height: calc(var(--u) * 32); object-fit: contain; margin: 0 0 calc(var(--u) * 1);
filter: drop-shadow(0 calc(var(--u) * 1.5) calc(var(--u) * 2.5) rgba(0,0,0,.35)); }
/* phone */
.tel { position: absolute; z-index: 3; width: var(--pw); aspect-ratio: var(--ar); left: var(--px); top: var(--py);
transform: translateX(-50%) rotate(var(--pr, 0deg)); transform-origin: 50% 40%;
border-radius: calc(var(--pw) * .16); padding: calc(var(--pw) * .024);
background: linear-gradient(140deg, #f4f4f6 0%, #9da0a8 8%, #3c3e44 20%, #1a1b1f 50%, #3c3e44 82%, #c8cad0 100%);
box-shadow: inset 0 0 0 calc(var(--pw) * .004) rgba(255,255,255,.6), inset 0 0 0 calc(var(--pw) * .011) #101114,
0 calc(var(--u) * 3) calc(var(--u) * 6) rgba(0,0,0,.35), 0 calc(var(--u) * 6) calc(var(--u) * 14) var(--shadow); }
.tel.back { z-index: 2; filter: saturate(.9) brightness(.9); }
.screen { position: relative; width: 100%; height: 100%; overflow: hidden; background: #000; border-radius: calc(var(--pw) * .137); }
.screen img.app { position: absolute; left: 0; bottom: 0; width: 100%; height: var(--appH); object-fit: cover; object-position: top; }
.screen::after { content: ""; position: absolute; inset: 0; z-index: 4; pointer-events: none;
background: linear-gradient(115deg, rgba(255,255,255,.12) 0%, rgba(255,255,255,.03) 24%, transparent 40%); }
/* status bar: the frame's top pixel row stretched, so it matches the game's own top colour */
.bar { position: absolute; top: 0; left: 0; right: 0; height: var(--barH); display: flex; align-items: center;
justify-content: space-between; padding: 0 8.5% 0 11.5%; font-family: -apple-system, 'SF Pro Text', sans-serif;
font-weight: 600; font-size: calc(var(--pw) * .041); z-index: 2; background: #0b0b10 center top / 100% 60000% no-repeat; }
.bar svg { height: calc(var(--pw) * .03); width: auto; margin-left: calc(var(--pw) * .012); }
.island { position: absolute; top: calc(var(--pw) * .025); left: 50%; transform: translateX(-50%); width: 30%; height: calc(var(--pw) * .082);
background: #000; border-radius: 999px; z-index: 3; }
.tablet .tel { border-radius: calc(var(--pw) * .065); padding: calc(var(--pw) * .02); }
.tablet .screen { border-radius: calc(var(--pw) * .048); }
.tablet .bar { padding: 0 5%; font-size: calc(var(--pw) * .024); }
.tablet .bar svg { height: calc(var(--pw) * .018); }
.tablet .island { width: calc(var(--pw) * .016); height: calc(var(--pw) * .016); top: calc(var(--pw) * .007); }
.android .tel { border-radius: calc(var(--pw) * .04); padding: calc(var(--pw) * .014);
background: linear-gradient(160deg, #3a3b41 0%, #1c1d21 30%, #111215 70%, #2c2d33 100%); }
.android .screen { border-radius: calc(var(--pw) * .028); }
.android .island { width: calc(var(--pw) * .013); height: calc(var(--pw) * .013); top: calc(var(--pw) * .009); }
.android .bar { padding: 0 4%; }
/* landscape: copy column + a Mac window (or the bare frame) */
.land .top { right: auto; width: var(--colW); padding: 0; left: var(--colL); }
.land h1 span { margin: 0 auto; }
.lwin { position: absolute; z-index: 3; left: var(--lx); top: var(--ly); width: var(--lw); border-radius: calc(var(--lw) * .012);
overflow: hidden; background: #1c1c1e; box-shadow: 0 0 0 calc(var(--u) * .12) rgba(255,255,255,.18),
0 calc(var(--u) * 2) calc(var(--u) * 5) rgba(0,0,0,.35), 0 calc(var(--u) * 5) calc(var(--u) * 12) var(--shadow); }
.lwin .tb { height: calc(var(--lw) * .028); display: flex; align-items: center; gap: calc(var(--lw) * .006); padding-left: calc(var(--lw) * .012);
background: linear-gradient(180deg, #3a3a3c, #2c2c2e); }
.lwin .tb i { width: calc(var(--lw) * .009); height: calc(var(--lw) * .009); border-radius: 50%; background: #ff5f57; }
.lwin .tb i:nth-child(2) { background: #febc2e; } .lwin .tb i:nth-child(3) { background: #28c840; }
.lwin img { display: block; width: 100%; aspect-ratio: var(--lar); object-fit: cover; }
.lwin.bare { border-radius: calc(var(--lw) * .02); }
.lwin.bare .tb { display: none; }
/* badges */
.badge { position: absolute; z-index: 9; display: flex; align-items: center; gap: calc(var(--u) * 2.2);
padding: calc(var(--u) * 1.7) calc(var(--u) * 3.6) calc(var(--u) * 1.7) calc(var(--u) * 1.7); border-radius: calc(var(--u) * 4);
background: linear-gradient(180deg, #ffffff 0%, #fff6e2 55%, #ffe3b0 100%);
box-shadow: inset 0 calc(var(--u) * -.7) 0 rgba(180,90,0,.18), 0 0 0 calc(var(--u) * .55) var(--ink),
0 calc(var(--u) * 1.1) 0 calc(var(--u) * .55) var(--ink), 0 calc(var(--u) * 3) calc(var(--u) * 5) rgba(0,0,0,.35);
transform: rotate(var(--br, 0deg)); color: var(--ink); white-space: nowrap; }
.badge .icon { flex: none; width: calc(var(--u) * 9); height: calc(var(--u) * 9); border-radius: calc(var(--u) * 2.6);
display: grid; place-items: center; background: var(--ic, linear-gradient(180deg, #ffd84d, #ff8a00));
box-shadow: inset 0 calc(var(--u) * .5) 0 rgba(255,255,255,.45), inset 0 calc(var(--u) * -.6) 0 rgba(0,0,0,.2); }
.badge .icon svg { width: 62%; height: 62%; overflow: visible; }
.badge b { display: block; font-family: var(--tf); font-weight: var(--tw); font-size: calc(var(--u) * 4.7); line-height: 1.02; }
.badge small { display: block; font-size: calc(var(--u) * 2.35); font-weight: 700; opacity: .72; margin-top: calc(var(--u) * .5); }
.badge.hot { background: linear-gradient(180deg, #fff27a 0%, #ffc21a 45%, #ff8a00 100%); padding: calc(var(--u) * 2) calc(var(--u) * 4.2); }
.badge.hot b { color: #fff; font-size: calc(var(--u) * 5.6); -webkit-text-stroke: calc(var(--u) * .5) #7a2c00; paint-order: stroke fill;
text-shadow: 0 calc(var(--u) * .5) 0 #7a2c00; }
.badge .stars { display: flex; gap: calc(var(--u) * .4); margin-right: calc(var(--u) * .6); }
.badge .stars svg { width: calc(var(--u) * 6.4); height: calc(var(--u) * 6.4); }
.badge .stars svg:nth-child(2) { transform: translateY(calc(var(--u) * -1)) scale(1.15); }
</style>
</head>
<body>
<div id="c"></div>
<script type="module">
const q = new URLSearchParams(location.search);
const W = +q.get('w'), H = +q.get('h'), LANG = q.get('lang'), SID = q.get('s'), V = q.get('v') ?? '';
const tablet = q.get('device') === 'tablet', LAND = q.get('device') === 'landscape';
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(LANG)}&s=${encodeURIComponent(SID)}&v=${encodeURIComponent(V)}`)).json();
const S = job.scene, C = job.copy, P = job.palette;
const u = LAND ? H / 100 : W / 100, Hu = H / u;
const dev = LAND ? job.landscape : tablet ? job.tablet : job.phone; // { w, h, bar } in CSS px of the raw frame
const FR = `/frames/${encodeURIComponent(LANG)}/${LAND ? 'landscape' : tablet ? 'tablet' : 'phone'}/`;
const MX = W * .07, MT = H * .05; // safe margins (lib.ts SAFE_MARGIN)
const colW = W * .36, colL = job.side === 'right' ? W - MX - colW : MX;
document.documentElement.lang = LANG;
const RTL = !!job.rtl;
const R = document.documentElement.style;
R.setProperty('--W', W + 'px'); R.setProperty('--H', H + 'px'); R.setProperty('--u', u + 'px');
R.setProperty('--ink', P.ink); R.setProperty('--glow', P.glow); R.setProperty('--shadow', P.shadow);
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(180deg, ${P.bg.join(', ')})` : P.bg)); // palette.ts: stops, colour, gradient string or photo + overlay
// A script the title font lacks (CJK, Arabic, Cyrillic, ...): the whole line switches to the Noto subset so a word
// never mixes two typefaces.
const T = job.fonts.title, B = job.fonts.body, X = job.fonts.script;
// titleStack / bodyStack (from shots fonts): script face, the configured font, then any automatic Noto fallback
const fstack = (st, def) => (st ?? (X ? [X, def] : [def])).map(f => `'${f}'`).join(', ') + ', sans-serif';
R.setProperty('--tf', fstack(job.fonts.titleStack, T));
R.setProperty('--bf', fstack(job.fonts.bodyStack, B));
R.setProperty('--tw', X ? 900 : job.fonts.titleWeight ?? 400);
const CJK = /^(ja|ko|zh)/.test(LANG);
R.setProperty('--lh', /^(ar|hi|fa|ur|th)/.test(LANG) ? 1.28 : CJK ? 1.12 : .98);
if (X) { R.setProperty('--ls', '0'); R.setProperty('--kls', '0'); }
if (CJK) R.setProperty('--sk', '.06');
if (LANG.startsWith('ja')) document.documentElement.style.wordBreak = 'auto-phrase';
if (LANG.startsWith('ko')) document.documentElement.style.wordBreak = 'keep-all';
if (tablet) document.body.classList.add('tablet');
if (q.get('style') === 'android') document.body.classList.add('android');
if (LAND) { document.body.classList.add('land'); R.setProperty('--colW', colW + 'px'); R.setProperty('--colL', colL + 'px'); }
const tall = !LAND && Hu > 200; // iOS 6.9" / 6.5" vs 16:9
const wide = Hu < 150; // 3:4 iPad
R.setProperty('--top', Math.max(tall ? 9 : wide ? 4 : 5.5, MT / u));
R.setProperty('--fs', LAND ? (S.hero ? 9 : 10) : S.hero ? (tall ? 10.5 : wide ? 7 : 8.6) : (tall ? 12 : wide ? 7.6 : 9.8));
R.setProperty('--sub', LAND ? 3.6 : tall ? 3.75 : wide ? 2.7 : 3.3);
const AR = dev.w / (dev.h + dev.bar);
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
R.setProperty('--appH', `${100 * dev.h / (dev.h + dev.bar)}%`);
R.setProperty('--barH', `${100 * dev.bar / (dev.h + dev.bar)}%`);
const I = {
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="13" r="8.5" fill="#fff" stroke="#5a2300" stroke-width="2"/><path d="M12 8v5l3.5 2.2" stroke="#5a2300" stroke-width="2.4" fill="none" stroke-linecap="round"/><rect x="9" y="1.5" width="6" height="3" rx="1.2" fill="#5a2300"/></svg>',
bolt: '<svg viewBox="0 0 24 24"><path d="M13.5 1.5 4 14h6.5L9.5 22.5 20 9.5h-6.8z" fill="#fff" stroke="#5a2300" stroke-width="1.8" stroke-linejoin="round"/></svg>',
burst: '<svg viewBox="0 0 24 24"><path d="M12 1l2.3 6 6.2-2-3.4 5.6 5.4 3.4-6.4.9.9 6.5L12 17l-5 4.4.9-6.5-6.4-.9 5.4-3.4L3.5 5l6.2 2z" fill="#fff" stroke="#5a2300" stroke-width="1.6" stroke-linejoin="round"/></svg>',
cube: '<svg viewBox="0 0 24 24"><path d="M12 2 21 7v10l-9 5-9-5V7z" fill="#fff" stroke="#0a4a2a" stroke-width="1.8" stroke-linejoin="round"/><path d="M3 7l9 5 9-5M12 12v10" stroke="#0a4a2a" stroke-width="1.8" fill="none"/></svg>',
map: '<svg viewBox="0 0 24 24"><path d="M3 6l6-2.5 6 2.5 6-2.5v14.5L15 20.5 9 18l-6 2.5z" fill="#fff" stroke="#06336e" stroke-width="1.8" stroke-linejoin="round"/><path d="M9 3.5V18M15 6v14.5" stroke="#06336e" stroke-width="1.8"/></svg>',
star: '<svg viewBox="0 0 24 24"><path d="M12 1.8l3 6.4 7 .9-5.1 4.8 1.3 6.9L12 17.4l-6.2 3.4 1.3-6.9L2 9.1l7-.9z" fill="url(#sg)" stroke="#6b2c00" stroke-width="1.6" stroke-linejoin="round"/><defs><linearGradient id="sg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff59a"/><stop offset=".55" stop-color="#ffcc1a"/><stop offset="1" stop-color="#ff9000"/></linearGradient></defs></svg>',
grid: '<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="3" fill="#fff" stroke="#5e0636" stroke-width="1.8"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18" stroke="#5e0636" stroke-width="1.5"/></svg>',
gift: '<svg viewBox="0 0 24 24"><rect x="3" y="9" width="18" height="12" rx="2" fill="#fff" stroke="#5e0636" stroke-width="1.8"/><rect x="2" y="6" width="20" height="4" rx="1.5" fill="#fff" stroke="#5e0636" stroke-width="1.8"/><path d="M12 6v15M12 6C10 2 6 3 7.5 6M12 6c2-4 6-3 4.5 0" stroke="#5e0636" stroke-width="1.8" fill="none"/></svg>',
offline: '<svg viewBox="0 0 24 24"><path d="M2.5 9a14 14 0 0 1 19 0M6 12.5a9 9 0 0 1 12 0M9.4 16a4 4 0 0 1 5.2 0" stroke="#fff" stroke-width="2.4" fill="none" stroke-linecap="round"/><circle cx="12" cy="19.3" r="1.6" fill="#fff"/><path d="M3 3l18 18" stroke="#6e1300" stroke-width="4.4" stroke-linecap="round"/><path d="M3 3l18 18" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/></svg>',
flag: '<svg viewBox="0 0 24 24"><path d="M5 22V3" stroke="#6e1300" stroke-width="2.4" stroke-linecap="round"/><path d="M5 3.5h13l-3 4 3 4H5z" fill="#fff" stroke="#6e1300" stroke-width="1.8" stroke-linejoin="round"/></svg>',
trophy: '<svg viewBox="0 0 24 24"><path d="M7 3h10v5a5 5 0 0 1-10 0z" fill="#fff" stroke="#6b2c00" stroke-width="1.8" stroke-linejoin="round"/><path d="M7 5H3.5a3.5 3.5 0 0 0 4 4M17 5h3.5a3.5 3.5 0 0 1-4 4M12 13v4M8 21h8l-1-4H9z" stroke="#6b2c00" stroke-width="1.8" fill="#fff" stroke-linejoin="round"/></svg>',
heart: '<svg viewBox="0 0 24 24"><path d="M12 21s-8.5-5.3-8.5-11.2A4.8 4.8 0 0 1 12 6.6a4.8 4.8 0 0 1 8.5 3.2C20.5 15.7 12 21 12 21z" fill="#fff" stroke="#5e0636" stroke-width="1.8" stroke-linejoin="round"/></svg>',
};
const ICON_BG = {
gold: 'linear-gradient(180deg,#ffe05a,#ff8a00)', green: 'linear-gradient(180deg,#8ff06a,#16a34a)',
blue: 'linear-gradient(180deg,#7fe0ff,#1f7aff)', violet: 'linear-gradient(180deg,#c49bff,#6a2fe0)',
pink: 'linear-gradient(180deg,#ffa3c8,#e8306f)', red: 'linear-gradient(180deg,#ff8a6a,#e4251b)',
};
// seeded rng: every render of a scene is identical
let seed = 0; for (const ch of SID + JSON.stringify(P.bg)) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0;
const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
const c = document.getElementById('c');
// RTL: keep number runs ("72 / 80") left-to-right inside right-to-left text
const esc = s => { const t = String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;');
return RTL ? t.replace(/\d+(?:\s*\/\s*\d+)?/g, m => '⁦' + m + '⁩') : t; };
let top = '';
if (S.hero && job.logo) top += `<img class="logo" src="/logo">`;
else if (!S.hero && C.eyebrow) top += `<div class="eyebrow"><b>${String(job.number).padStart(2, '0')}</b>${esc(C.eyebrow)}</div>`;
top += `<h1><span>${esc(C.title?.[0])}</span><span>${esc(C.title?.[1])}</span></h1>`;
if (!S.hero && C.sub) top += `<p class="sub">${esc(C.sub)}</p>`;
c.innerHTML = `<div class="glow"></div><div class="rays"></div><div class="vign"></div><div class="top"${RTL ? ' dir="rtl"' : ''}>${top}</div>`;
await document.fonts.ready;
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
const overflow = []; // read by `shots render` -> report.json -> shots check / gallery
for (const [i, sp] of [...c.querySelectorAll('h1 span')].entries()) { // shrink a line that would overflow (long words)
const max = LAND ? colW : W - 2 * MX, wd = sp.getBoundingClientRect().width;
let f = 1; while (wd * f > max && f > .55) f -= .02;
if (f < 1) sp.style.fontSize = `calc(${getComputedStyle(sp).fontSize} * ${f.toFixed(2)})`;
if (wd * f > max + 1) overflow.push(`title line ${i + 1} is too long even at 55 % size: shorten it`);
else if (f < .7) overflow.push(`title line ${i + 1} shrunk to ${Math.round(f * 100)} %: consider shorter words`);
}
const subEl = c.querySelector('p.sub');
if (subEl) { const lines = Math.round(subEl.getBoundingClientRect().height / parseFloat(getComputedStyle(subEl).lineHeight)); if (lines > 3) overflow.push(`subtitle wraps to ${lines} lines (max 3)`); }
const eb = c.querySelector('.eyebrow');
if (eb && eb.getBoundingClientRect().width > (LAND ? colW : W - 2 * MX)) overflow.push('eyebrow is wider than the image');
const textBottom = c.querySelector('.top').getBoundingClientRect().bottom / u;
portrait: {
if (LAND) {
const topEl = c.querySelector('.top'), bh = topEl.getBoundingClientRect().height;
topEl.style.top = Math.max(MT, (H - bh) / 2) + 'px';
if (bh > H - MT * 2) overflow.push('text block is taller than the image: shorten the copy');
const f = S.frames[0], src = FR + encodeURIComponent(f.src), bare = q.get('frame') === 'none';
const x0 = job.side === 'right' ? W * .04 : colL + colW + W * .05, x1 = job.side === 'right' ? colL - W * .05 : W - W * .04;
let lw = x1 - x0;
const lh = k => k / (dev.w / dev.h) + (bare ? 0 : k * .028);
if (lh(lw) > H * .88) lw = lw * H * .88 / lh(lw);
R.setProperty('--lar', `${dev.w} / ${dev.h}`);
R.setProperty('--rayY', H / 2 + 'px'); R.setProperty('--rayRot', (rnd() * 20) + 'deg');
c.insertAdjacentHTML('beforeend', `<div class="lwin front${bare ? ' bare' : ''}" style="--lw:${lw}px;--lx:${(x0 + x1 - lw) / 2}px;--ly:${(H - lh(lw)) / 2}px"><div class="tb"><i></i><i></i><i></i></div><img src="${src}"></div>`);
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
break portrait;
}
const frames = S.frames.map(f => ({ x: 0, dy: 0, scale: 1, rotate: 0, ...f }));
const front = frames[0];
const pTop = textBottom + (S.hero ? 4 : 5.5);
const room = Hu - pTop + (S.over ?? 7);
const pw = Math.min(tablet ? 82 : 78, Math.max(56, room * AR)) * front.scale;
const ph = pw / AR;
R.setProperty('--rayY', (pTop + ph * .3) * u + 'px');
R.setProperty('--rayRot', (rnd() * 20) + 'deg');
const BAR = `<span>9:41</span><span style="display:flex;align-items:center">
<svg viewBox="0 0 18 12"><g fill="#fff"><rect x="0" y="8" width="3" height="4" rx=".8"/><rect x="5" y="5.5" width="3" height="6.5" rx=".8"/><rect x="10" y="3" width="3" height="9" rx=".8"/><rect x="15" y="0" width="3" height="12" rx=".8"/></g></svg>
<svg viewBox="0 0 16 12"><path fill="#fff" d="M8 2.2c2.3 0 4.4.9 6 2.4l1.3-1.3A10.3 10.3 0 0 0 8 .4C5.2.4 2.6 1.5.7 3.3L2 4.6a8.5 8.5 0 0 1 6-2.4zm0 3.6c1.3 0 2.5.5 3.4 1.3l1.3-1.3A6.6 6.6 0 0 0 8 4c-1.8 0-3.4.7-4.7 1.8l1.3 1.3C5.5 6.3 6.7 5.8 8 5.8zm0 3.6c.4 0 .8.2 1.1.4L8 11.6 6.9 9.8c.3-.2.7-.4 1.1-.4z"/></svg>
<svg viewBox="0 0 27 13"><rect x=".5" y=".5" width="23" height="12" rx="3.5" fill="none" stroke="rgba(255,255,255,.45)"/><rect x="2" y="2" width="20" height="9" rx="2.2" fill="#fff"/><path d="M25 4.5v4c.8-.3 1.4-1.1 1.4-2s-.6-1.7-1.4-2z" fill="rgba(255,255,255,.45)"/></svg></span>`;
let html = '';
for (let i = 0; i < 26; i++) {
const x = rnd() * 100, y = textBottom + rnd() * (Hu - textBottom);
if (i % 2) html += `<i class="spark" style="left:${x * u}px;top:${y * u}px;--s:${(1.6 + rnd() * 3.2) * u}px;--r:${rnd() * 45}deg;--o:${.55 + rnd() * .45}"></i>`;
else html += `<i class="dot" style="left:${x * u}px;top:${y * u}px;--s:${(3 + rnd() * 9) * u}px"></i>`;
}
for (let i = 0; i < 8; i++) {
const x = i % 2 ? 1 + rnd() * 6 : 93 + rnd() * 6, y = 2 + rnd() * (textBottom - 4);
html += `<i class="spark" style="left:${x * u}px;top:${y * u}px;--s:${(1.2 + rnd() * 2) * u}px;--r:0deg;--o:${.4 + rnd() * .4}"></i>`;
}
const phone = (f, cls) => {
const w = pw / front.scale * f.scale, src = FR + encodeURIComponent(f.src);
return `<div class="tel ${cls}" style="--pw:${w * u}px;--px:${(50 + f.x) * u}px;--py:${(pTop + f.dy) * u}px;--pr:${f.rotate}deg"><div class="screen">
<div class="bar" style="background-image:url('${src}')">${BAR}</div><div class="island"></div><img class="app" src="${src}"></div></div>`;
};
for (const f of frames.slice(1)) html += phone(f, 'back');
html += phone(front, 'front');
(S.badges ?? []).forEach((b, i) => {
const [big, small] = (C.badges ?? [])[i] ?? [''];
if (!big) return;
const ic = b.stars ? `<span class="stars">${I.star}${I.star}${I.star}</span>`
: b.hot ? '' : `<span class="icon" style="--ic:${ICON_BG[b.iconBg] ?? b.iconBg ?? ICON_BG.gold}">${I[b.icon] ?? I.star}</span>`;
const pos = b.side === 'l' ? `left:${4 * u}px` : `right:${4 * u}px`;
html += `<div class="badge${b.hot ? ' hot' : ''}"${RTL ? ' dir="rtl"' : ''} style="${pos};top:${Math.min(pTop + ph * b.y, Hu - 18) * u}px;--br:${b.rot || 0}deg">${ic}
<div${RTL ? ' dir="rtl"' : ''}><b>${esc(big)}</b>${small ? `<small>${esc(small)}</small>` : ''}</div></div>`;
});
c.insertAdjacentHTML('beforeend', html);
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
if (textBottom > Hu * .48) overflow.push(`text block covers ${Math.round(textBottom / Hu * 100)} % of the height (the game should be the star)`);
for (const b of c.querySelectorAll('.badge')) { const r = b.getBoundingClientRect(); if (r.left < 0 || r.right > W || r.bottom > H) overflow.push(`badge "${b.textContent.trim().slice(0, 30)}" runs off the image`); }
}
document.body.dataset.overflow = JSON.stringify(overflow);
// text and logo boxes for the safe-margin rule (shots check)
document.body.dataset.boxes = JSON.stringify([...c.querySelectorAll('.top .logo, .top .eyebrow, .top h1 span, .top p.sub')].map(e => { const r = e.getBoundingClientRect();
return { el: e.matches('.logo') ? 'logo' : e.matches('.eyebrow') ? 'eyebrow' : e.matches('p') ? 'subtitle' : 'title', x: r.x, y: r.y, w: r.width, h: r.height }; }).filter(b => b.w > 0 && b.h > 0));
// the front device only: back phones of a fanned layout peek in from the side on purpose
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel.front, .lwin')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
document.body.dataset.ready = '1';
</script>
</body>
</html>