storebound/cli/shots/template-app.html

220 lines
16 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/fonts/fonts.css">
<!--
Store screenshot template, app style (shots.style 'app', the default for the app / subscription-app profiles).
template-app.html?s=<scene id>&w=<px>&h=<px>&lang=<id>[&device=tablet][&style=android][&v=<variant>]
Calm: a soft gradient (or solid) background, one clean device frame, a headline (two lines) and a subline. No
sunburst, no sparkles; badges only when the copy has them, drawn as quiet chips. Same /job.json as template.html
(palette: bg = gradient stops, ink = headline, glow = accent, shadow = device shadow).
Layout: everything is in u = W / 100; the text block is measured first, then the device fills the room below it.
device=landscape: u = H / 100, copy column on one side, the landscape frame in a Mac window (frame=window) or bare
(frame=none) on the other (as template.html). Safe margins: 7 % of the width left / right, 5 % of the height at the
top; text and logo boxes go to body.dataset.boxes for shots check.
-->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { overflow: hidden; background: #fff; }
body { font-family: var(--bf); -webkit-font-smoothing: antialiased; color: var(--ink); }
#c { position: relative; overflow: hidden; width: var(--W); height: var(--H); isolation: isolate; background: var(--bg); }
.soft { position: absolute; left: 50%; top: var(--softY); width: calc(var(--u) * 140); height: calc(var(--u) * 110); z-index: -1;
transform: translate(-50%, -50%); border-radius: 50%; background: radial-gradient(closest-side, var(--accentSoft), transparent); }
.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 { margin-bottom: calc(var(--u) * 2.4); font-size: calc(var(--u) * 2.9); font-weight: 700; letter-spacing: var(--kls, .14em);
text-transform: uppercase; color: var(--accent); line-height: 1.2; }
h1 { font-family: var(--tf); font-weight: var(--tw); line-height: var(--lh); letter-spacing: var(--ls, -.02em);
font-size: calc(var(--u) * var(--fs)); color: var(--ink); }
h1 span { display: block; width: max-content; max-width: none; margin: 0 auto; white-space: nowrap; }
h1 span + span { color: var(--line2); }
p.sub { margin-top: calc(var(--u) * 2.4); max-width: calc(var(--u) * 78); font-size: calc(var(--u) * var(--sub));
line-height: 1.38; font-weight: 500; color: var(--ink); opacity: .72; text-wrap: balance; }
.logo { width: min(100%, calc(var(--u) * 60)); max-height: calc(var(--u) * 26); object-fit: contain; margin-bottom: calc(var(--u) * 3); }
/* device: a plain modern phone, dark bezel, soft shadow */
.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) * .155); padding: calc(var(--pw) * .022); background: #121317;
box-shadow: inset 0 0 0 calc(var(--pw) * .005) #3a3c44, 0 calc(var(--u) * 2) calc(var(--u) * 4) rgba(0,0,0,.08),
0 calc(var(--u) * 5) calc(var(--u) * 12) var(--shadow); }
.tel.back { z-index: 2; opacity: .96; }
.screen { position: relative; width: 100%; height: 100%; overflow: hidden; background: #fff; border-radius: calc(var(--pw) * .135); }
.screen img.app { position: absolute; left: 0; bottom: 0; width: 100%; height: var(--appH); object-fit: cover; object-position: top; }
.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', var(--bf), sans-serif;
font-weight: 600; font-size: calc(var(--pw) * .041); z-index: 2; color: var(--barInk, #fff);
background: #ffffff 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) * .06); padding: calc(var(--pw) * .018); }
.tablet .screen { border-radius: calc(var(--pw) * .044); }
.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); }
.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%; }
.land .top { right: auto; width: var(--colW); padding: 0; left: var(--colL); }
.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 calc(var(--u) * 2) calc(var(--u) * 4) rgba(0,0,0,.08), 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: #e8e8ea; }
.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; }
.chip { position: absolute; z-index: 9; display: flex; flex-direction: column; padding: calc(var(--u) * 1.8) calc(var(--u) * 3);
border-radius: calc(var(--u) * 3); background: rgba(255,255,255,.92); color: #111827; white-space: nowrap;
box-shadow: 0 calc(var(--u) * 1) calc(var(--u) * 3.5) rgba(15,23,42,.14); }
.chip b { font-family: var(--tf); font-weight: 700; font-size: calc(var(--u) * 3.6); line-height: 1.1; }
.chip small { font-size: calc(var(--u) * 2.3); font-weight: 500; opacity: .65; margin-top: calc(var(--u) * .3); }
</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;
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;
// #rrggbb -> rgba with alpha, for the soft accent glow
const rgba = (hex, a) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return hex; const n = parseInt(m[1], 16); return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`; };
const lum = hex => { const m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return 1; const n = parseInt(m[1], 16); return (.299 * (n >> 16) + .587 * ((n >> 8) & 255) + .114 * (n & 255)) / 255; };
const tone = P.bgTone ?? (Array.isArray(P.bg) ? P.bg[1] : P.bg);
const dark = !!P.photo && P.photo.overlay >= .35 || lum(tone) < .45;
R.setProperty('--W', W + 'px'); R.setProperty('--H', H + 'px'); R.setProperty('--u', u + 'px');
R.setProperty('--ink', P.ink); R.setProperty('--accent', P.glow); R.setProperty('--shadow', P.shadow);
R.setProperty('--line2', dark ? P.glow : P.ink);
R.setProperty('--accentSoft', rgba(P.glow, dark ? .28 : .14));
// bgCss from job.json (palette.ts bgCss / photoCss): stops, one colour, a CSS gradient string, or a photo under an overlay
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(180deg, ${P.bg.join(', ')})` : P.bg));
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', 750);
const CJK = /^(ja|ko|zh)/.test(LANG);
R.setProperty('--lh', /^(ar|hi|fa|ur|th)/.test(LANG) ? 1.3 : CJK ? 1.18 : 1.06);
if (X) { R.setProperty('--ls', '0'); R.setProperty('--kls', '0'); }
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, wide = !LAND && Hu < 150;
R.setProperty('--top', Math.max(tall ? 9.5 : wide ? 4.5 : 6, MT / u));
R.setProperty('--fs', LAND ? 8.5 : tall ? 8.6 : wide ? 5.6 : 7);
R.setProperty('--sub', LAND ? 3.4 : tall ? 3.7 : wide ? 2.6 : 3.2);
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 c = document.getElementById('c');
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">`;
if (C.eyebrow) top += `<div class="eyebrow">${esc(C.eyebrow)}</div>`;
top += `<h1><span>${esc(C.title?.[0])}</span>${C.title?.[1] ? `<span>${esc(C.title[1])}</span>` : ''}</h1>`;
if (C.sub) top += `<p class="sub">${esc(C.sub)}</p>`;
c.innerHTML = `<div class="soft"></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 = [];
for (const [i, sp] of [...c.querySelectorAll('h1 span')].entries()) {
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 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('--softY', H / 2 + 'px');
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 + (tall ? 6 : 4.5);
const room = Hu - pTop + (S.over ?? 9);
const pw = Math.min(tablet ? 80 : 74, Math.max(52, room * AR)) * front.scale;
const ph = pw / AR;
R.setProperty('--softY', (pTop + ph * .35) * u + 'px');
// status bar text colour from the frame's own top row (dark text on a light app bar)
async function barInk(src) {
try {
const img = new Image(); img.src = src; await img.decode();
const cv = document.createElement('canvas'); cv.width = 8; cv.height = 2;
const g = cv.getContext('2d'); g.drawImage(img, 0, 0, img.naturalWidth, 2, 0, 0, 8, 2);
const d = g.getImageData(0, 0, 8, 1).data; let L = 0;
for (let i = 0; i < d.length; i += 4) L += .299 * d[i] + .587 * d[i + 1] + .114 * d[i + 2];
return L / (d.length / 4) > 150 ? '#111' : '#fff';
} catch { return '#fff'; }
}
const BAR = `<span>9:41</span><span style="display:flex;align-items:center">
<svg viewBox="0 0 18 12"><g fill="currentColor"><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="currentColor" 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="currentColor" stroke-opacity=".45"/><rect x="2" y="2" width="20" height="9" rx="2.2" fill="currentColor"/><path d="M25 4.5v4c.8-.3 1.4-1.1 1.4-2s-.6-1.7-1.4-2z" fill="currentColor" fill-opacity=".45"/></svg></span>`;
let html = '';
const phone = async (f, cls) => {
const w = pw / front.scale * f.scale, src = FR + encodeURIComponent(f.src);
const ink = await barInk(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}');--barInk:${ink}">${BAR}</div><div class="island"></div><img class="app" src="${src}"></div></div>`;
};
for (const f of frames.slice(1)) html += await phone(f, 'back');
html += await phone(front, 'front');
(S.badges ?? []).forEach((b, i) => {
const [big, small] = (C.badges ?? [])[i] ?? [''];
if (!big) return;
const pos = b.side === 'l' ? `left:${5 * u}px` : `right:${5 * u}px`;
html += `<div class="chip"${RTL ? ' dir="rtl"' : ''} style="${pos};top:${Math.min(pTop + ph * b.y, Hu - 14) * u}px"><b>${esc(big)}</b>${small ? `<small>${esc(small)}</small>` : ''}</div>`;
});
c.insertAdjacentHTML('beforeend', html);
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
if (textBottom > Hu * .45) overflow.push(`text block covers ${Math.round(textBottom / Hu * 100)} % of the height (the app should be the star)`);
for (const b of c.querySelectorAll('.chip')) { 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);
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>