App screenshot style (shots.style app): calm gradient, device frame, headline + subline; demo app Tidy Lists

This commit is contained in:
CRTkafa 2026-10-11 01:47:09 +08:00
parent 7f0bb7c2dd
commit b21e4a0920
16 changed files with 482 additions and 26 deletions

View file

@ -1,4 +1,4 @@
// Raw frames from a running build of your game, from any engine. // Raw frames from a running build of your app or game, from any engine.
// storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder] // storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder]
// [--dry] [--keep-booted] // [--dry] [--keep-booted]
// Frames go to <shots.frames>/<lang>/<device>/<name>.png: the screen BELOW the status bar (the template draws one). // Frames go to <shots.frames>/<lang>/<device>/<name>.png: the screen BELOW the status bar (the template draws one).
@ -19,7 +19,7 @@
// left running with its status bar restored. --dry prints the simctl / adb commands without running them. // left running with its status bar restored. --dry prints the simctl / adb commands without running them.
// Simulator / emulator screenshots are full screen: cropTop rows (the status bar) are cut so the frame fits the template // Simulator / emulator screenshots are full screen: cropTop rows (the status bar) are cut so the frame fits the template
// (default per device class from the frame dims: phone 162 px of a 6.9" iPhone, tablet 48 px of a 13" iPad with apple.ipad). // (default per device class from the frame dims: phone 162 px of a 6.9" iPhone, tablet 48 px of a 13" iPad with apple.ipad).
// The game needs switches for this: language, scene, fixed seed, no ads / tutorial, freeze. See the store-screenshots skill. // The app or game needs switches for this: language, scene, fixed seed, no ads / tutorial, freeze. See the store-screenshots skill.
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';

View file

@ -22,7 +22,7 @@ export function validateCapture(c: ShotsCapture | undefined): string[] {
const e: string[] = []; const e: string[] = [];
const src = c.source ?? 'web'; const src = c.source ?? 'web';
if (!SOURCES.includes(src)) return [`shots.capture.source: one of ${SOURCES.join(', ')}`]; if (!SOURCES.includes(src)) return [`shots.capture.source: one of ${SOURCES.join(', ')}`];
if (src === 'web' && !/^https?:\/\//.test(c.baseUrl ?? '')) e.push('shots.capture.baseUrl: http(s) URL of the running game (source web)'); if (src === 'web' && !/^https?:\/\//.test(c.baseUrl ?? '')) e.push('shots.capture.baseUrl: http(s) URL of the running app or game (source web)');
if (src === 'ios-sim') { if (src === 'ios-sim') {
if (!c.ios?.bundleId) e.push('shots.capture.ios.bundleId: bundle id of the app on the simulator (source ios-sim)'); if (!c.ios?.bundleId) e.push('shots.capture.ios.bundleId: bundle id of the app on the simulator (source ios-sim)');
if (!c.ios?.device && !c.ios?.devices?.phone) e.push('shots.capture.ios.device: simulator name or UDID, e.g. "iPhone 17 Pro Max"'); if (!c.ios?.device && !c.ios?.devices?.phone) e.push('shots.capture.ios.device: simulator name or UDID, e.g. "iPhone 17 Pro Max"');

View file

@ -0,0 +1,53 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/fonts/fonts.css">
<!-- Play feature graphic, app style, 1024 x 500: logo (or the app name) on the left on a calm gradient, an upright phone
with one frame on the right. feature-app.html?lang=<id>[&v=<variant>]. Rendered by `storebound shots render` when
the screenshot style is app. Keep text out of it apart from the name: Play overlays its own UI. -->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1024px; height: 500px; overflow: hidden; background: #fff; }
#c { position: relative; width: 1024px; height: 500px; overflow: hidden; isolation: isolate; background: var(--bg); }
.soft { position: absolute; left: 760px; top: 260px; width: 700px; height: 600px; transform: translate(-50%, -50%); z-index: -1;
border-radius: 50%; background: radial-gradient(closest-side, var(--accentSoft), transparent); }
.logo { position: absolute; left: 330px; top: 250px; width: 440px; max-height: 220px; object-fit: contain; transform: translate(-50%, -50%); z-index: 5; }
.name { position: absolute; left: 330px; top: 250px; transform: translate(-50%, -50%); z-index: 5; width: 520px; text-align: center;
font-family: var(--tf); font-weight: 750; font-size: 76px; line-height: 1.02; letter-spacing: -.02em; color: var(--ink); }
.tel { position: absolute; left: 690px; top: 40px; width: 230px; aspect-ratio: var(--ar); z-index: 3; border-radius: 36px; padding: 5px;
background: #121317; box-shadow: inset 0 0 0 1px #3a3c44, 0 22px 50px var(--shadow); }
.screen { position: relative; width: 100%; height: 100%; overflow: hidden; border-radius: 31px; background: #fff; }
.screen img { position: absolute; left: 0; bottom: 0; width: 100%; height: var(--appH); object-fit: cover; object-position: top; }
.screen .bar { position: absolute; left: 0; right: 0; top: 0; height: var(--barH); background: #fff center top / 100% 60000% no-repeat; }
.island { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); width: 30%; height: 19px; background: #000; border-radius: 99px; }
</style>
</head>
<body>
<div id="c">
<div class="soft"></div>
<div class="tel"><div class="screen"><div class="bar" id="bar"></div><img id="f"><div class="island"></div></div></div>
</div>
<script type="module">
const q = new URLSearchParams(location.search);
const lang = q.get('lang'), V = q.get('v') ?? '';
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(lang)}&feature=1&v=${encodeURIComponent(V)}`)).json();
const P = job.palette, R = document.documentElement.style, dev = job.phone;
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})`; };
R.setProperty('--bg', `linear-gradient(135deg, ${P.bg[0]} 0%, ${P.bg[1]} 55%, ${P.bg[2]} 100%)`);
R.setProperty('--ink', P.ink); R.setProperty('--shadow', P.shadow); R.setProperty('--accentSoft', rgba(P.glow, .18));
R.setProperty('--tf', `'${job.fonts.title}', sans-serif`);
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 src = `/frames/${encodeURIComponent(lang)}/phone/${encodeURIComponent(job.frame)}`;
document.getElementById('f').src = src;
document.getElementById('bar').style.backgroundImage = `url('${src}')`;
c.insertAdjacentHTML('beforeend', job.logo ? '<img class="logo" src="/logo">' : `<div class="name">${String(job.appName).replace(/</g, '&lt;')}</div>`);
await document.fonts.ready;
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
document.body.dataset.ready = '1';
</script>
</body>
</html>

View file

@ -8,7 +8,8 @@ import { join } from 'node:path';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { need } from '../lib/ctx';
import { done, log } from '../lib/log'; import { done, log } from '../lib/log';
import { type CopyFile, copyText, scriptFont } from './lib'; import { shotsStyle } from '../lib/config';
import { type CopyFile, copyText, scriptFont, shotFonts } from './lib';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'; const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') + '·'; const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') + '·';
@ -30,7 +31,7 @@ export async function run(ctx: Ctx): Promise<void> {
const copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile; const copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile;
const dir = join(root, '.storebound', 'fonts'); const dir = join(root, '.storebound', 'fonts');
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
const title = shots.fonts?.title ?? 'Lilita One', body = shots.fonts?.body ?? 'Montserrat'; const { title, body, titleVariable } = shotFonts(shots, shotsStyle(config));
const latin = new Set(ASCII); const latin = new Set(ASCII);
const scripts = new Map<string, { family: string; chars: Set<string> }>(); const scripts = new Map<string, { family: string; chars: Set<string> }>();
@ -46,7 +47,7 @@ export async function run(ctx: Ctx): Promise<void> {
const latinChars = [...latin].sort().join(''); const latinChars = [...latin].sort().join('');
let css = '/* Generated by storebound shots fonts (Google Fonts, SIL Open Font License 1.1), subset to the copy. */\n'; let css = '/* Generated by storebound shots fonts (Google Fonts, SIL Open Font License 1.1), subset to the copy. */\n';
const jobs: [string, string, string, boolean][] = [[title, 'title', latinChars, false], [body, 'body', latinChars, true], const jobs: [string, string, string, boolean][] = [[title, 'title', latinChars, titleVariable], [body, 'body', latinChars, true],
...[...scripts.entries()].map(([slug, s]) => [s.family, slug, [...s.chars].sort().join(''), true] as [string, string, string, boolean])]; ...[...scripts.entries()].map(([slug, s]) => [s.family, slug, [...s.chars].sort().join(''), true] as [string, string, string, boolean])];
for (const [family, slug, chars, variable] of jobs) { for (const [family, slug, chars, variable] of jobs) {
const bin = await fetchSubset(family, chars, variable); const bin = await fetchSubset(family, chars, variable);

View file

@ -27,14 +27,35 @@ export const PALETTES: Record<string, Palette> = {
candy: { bg: ['#d01f7a', '#ff4f8b', '#ff9f43'], glow: 'rgba(255,220,180,.9)', ink: '#5e0636', shadow: 'rgba(90,0,50,.45)' }, candy: { bg: ['#d01f7a', '#ff4f8b', '#ff9f43'], glow: 'rgba(255,220,180,.9)', ink: '#5e0636', shadow: 'rgba(90,0,50,.45)' },
}; };
export const palette = (p: string | Palette | undefined): Palette => { /** Calm palettes for the app style: bg = gradient stops, ink = headline colour, glow = accent (eyebrow, second
if (!p) return PALETTES.fire!; * headline line), shadow = device shadow. Light ones first, then dark ones (ocean, slate, night-ink). */
export const APP_PALETTES: Record<string, Palette> = {
mist: { bg: ['#eef2ff', '#e6ebff', '#f6f4ff'], glow: '#4f46e5', ink: '#111827', shadow: 'rgba(30,41,90,.22)' },
paper: { bg: ['#ffffff', '#f7f8fa', '#eef1f5'], glow: '#0f766e', ink: '#0f172a', shadow: 'rgba(15,23,42,.18)' },
mint: { bg: ['#ecfdf5', '#dcfce7', '#f0fdfa'], glow: '#059669', ink: '#064e3b', shadow: 'rgba(6,78,59,.2)' },
sand: { bg: ['#fffbeb', '#fef3c7', '#fff7ed'], glow: '#c2410c', ink: '#3b2a0b', shadow: 'rgba(90,50,10,.2)' },
rose: { bg: ['#fff1f2', '#ffe4e6', '#fdf2f8'], glow: '#e11d48', ink: '#4c0519', shadow: 'rgba(90,10,40,.2)' },
sky: { bg: ['#f0f9ff', '#e0f2fe', '#eef6ff'], glow: '#0369a1', ink: '#0c2a43', shadow: 'rgba(10,50,90,.2)' },
ocean: { bg: ['#0b1736', '#14306e', '#1d4ed8'], glow: '#93c5fd', ink: '#ffffff', shadow: 'rgba(0,0,0,.45)' },
slate: { bg: ['#0f172a', '#1e293b', '#334155'], glow: '#a5b4fc', ink: '#f8fafc', shadow: 'rgba(0,0,0,.5)' },
};
export const palette = (p: string | Palette | undefined, style: 'game' | 'app' = 'game'): Palette => {
const own = style === 'app' ? APP_PALETTES : PALETTES;
if (!p) return style === 'app' ? APP_PALETTES.mist! : PALETTES.fire!;
if (typeof p !== 'string') return p; if (typeof p !== 'string') return p;
const hit = PALETTES[p]; const hit = own[p] ?? PALETTES[p] ?? APP_PALETTES[p];
if (!hit) throw new Error(`unknown palette "${p}" (${Object.keys(PALETTES).join(', ')}) or pass { bg, glow, ink, shadow }`); if (!hit) throw new Error(`unknown palette "${p}" (${Object.keys(own).join(', ')}) or pass { bg, glow, ink, shadow }`);
return hit; return hit;
}; };
/** Screenshot fonts: shots.fonts, else per style (game: Lilita One titles + Montserrat; app: Inter for both). The app
* title is a variable font (bold headline weights). */
export function shotFonts(shots: Pick<ShotsConfig, 'fonts'> | undefined, style: 'game' | 'app'): { title: string; body: string; titleVariable: boolean } {
const d = style === 'app' ? { title: 'Inter', body: 'Inter' } : { title: 'Lilita One', body: 'Montserrat' };
return { title: shots?.fonts?.title ?? d.title, body: shots?.fonts?.body ?? d.body, titleVariable: style === 'app' };
}
export interface SceneCopy { eyebrow?: string; title: [string, string]; sub?: string; badges?: [string, string?][] } export interface SceneCopy { eyebrow?: string; title: [string, string]; sub?: string; badges?: [string, string?][] }
export type CopyFile = Record<string, Record<string, SceneCopy>>; export type CopyFile = Record<string, Record<string, SceneCopy>>;

View file

@ -1,4 +1,4 @@
// Gameplay clips for app previews / promo videos, from any engine. // App or gameplay clips for app previews / promo videos, from any engine.
// storebound shots record [--lang en,tr] [--only intro,combo] [--source web|ios-sim|android-emu|folder] [--dry] [--keep-booted] // storebound shots record [--lang en,tr] [--only intro,combo] [--source web|ios-sim|android-emu|folder] [--dry] [--keep-booted]
// Config: shots.video.record { source?, device?, clips: { <name>: { seconds, path | url | args | extras, waitFor?, delayMs? } } }; // Config: shots.video.record { source?, device?, clips: { <name>: { seconds, path | url | args | extras, waitFor?, delayMs? } } };
// the URL / simulator / emulator settings are the ones of shots.capture. Out: <shots.video.clips>/<lang>/NN-<name>.mp4 // the URL / simulator / emulator settings are the ones of shots.capture. Out: <shots.video.clips>/<lang>/NN-<name>.mp4
@ -11,7 +11,7 @@
// android-emu `adb shell screenrecord --time-limit <seconds>` on the running emulator / device (never booted, wiped or // android-emu `adb shell screenrecord --time-limit <seconds>` on the running emulator / device (never booted, wiped or
// rebooted by storebound), pulled and removed from the device. Demo-mode status bar, per-app language. // rebooted by storebound), pulled and removed from the device. Demo-mode status bar, per-app language.
// folder your own clips (screen recorder, engine capture, OBS): lists <clips>/<lang>/ or <clips>/ with durations. // folder your own clips (screen recorder, engine capture, OBS): lists <clips>/<lang>/ or <clips>/ with durations.
// Make the game playable hands-free for this (an autoplay / attract mode switch) or record yourself playing with // Make the app or game run hands-free for this (an autoplay / attract mode switch) or record yourself playing with
// --source folder. Apple: previews must show footage captured from the app itself. // --source folder. Apple: previews must show footage captured from the app itself.
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';

View file

@ -1,10 +1,12 @@
// Store screenshots + Play feature graphic at exact pixel sizes, from raw game frames and per-language copy. // Store screenshots + Play feature graphic at exact pixel sizes, from raw app or game frames and per-language copy.
// storebound shots render [--lang en,tr] [--size ios69,play,feature] [--scenes 1,3] [--variant b | --variants] // storebound shots render [--lang en,tr] [--size ios69,play,feature] [--scenes 1,3] [--variant b | --variants]
// [--no-sheet] [--port 8792] [--dry] // [--no-sheet] [--port 8792] [--dry]
// Out: <shots.out>/<lang>/<size>/NN.png (24-bit RGB: Play rejects alpha / palette PNGs), <lang>/feature/1024x500.png, // Out: <shots.out>/<lang>/<size>/NN.png (24-bit RGB: Play rejects alpha / palette PNGs), <lang>/feature/1024x500.png,
// <lang>/sheet.png (contact sheet of every size packed into a grid, for a quick review) and <shots.out>/report.json (text overflow flags // <lang>/sheet.png (contact sheet of every size packed into a grid, for a quick review) and <shots.out>/report.json (text overflow flags
// per image, read by `shots check` and `shots gallery`). Variants go to <shots.out>/<variant>/<lang>/... // per image, read by `shots check` and `shots gallery`). Variants go to <shots.out>/<variant>/<lang>/...
// Default sizes: defaultSizes() (ipad13 only with apple.ipad). Needs playwright + ffmpeg. // Default sizes: defaultSizes() (ipad13 only with apple.ipad). Needs playwright + ffmpeg.
// Style: shotsStyle() = shots.style, else app for the app / subscription-app profiles (template-app.html,
// feature-app.html: calm gradient, device frame, headline + subline), else game (template.html, feature.html).
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { join, relative } from 'node:path'; import { join, relative } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
@ -12,7 +14,8 @@ import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { need } from '../lib/ctx';
import { done, log, warn } from '../lib/log'; import { done, log, warn } from '../lib/log';
import { launch } from './browser'; import { launch } from './browser';
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib'; import { shotsStyle } from '../lib/config';
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, shotFonts, validateCopy, variantScenes } from './lib';
import { type RenderReport, mergeReport, readReport } from './report'; import { type RenderReport, mergeReport, readReport } from './report';
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
@ -51,7 +54,9 @@ export async function run(ctx: Ctx): Promise<void> {
if (!existsSync(join(fontsDir, 'fonts.css'))) warn('shots', 'no .storebound/fonts/fonts.css: run `storebound shots fonts` first (falling back to system fonts)'); if (!existsSync(join(fontsDir, 'fonts.css'))) warn('shots', 'no .storebound/fonts/fonts.css: run `storebound shots fonts` first (falling back to system fonts)');
const { phone, tablet } = frameDims(config); const { phone, tablet } = frameDims(config);
const rtl = new Map(config.languages.map(l => [l.id, !!l.rtl] as const)); const rtl = new Map(config.languages.map(l => [l.id, !!l.rtl] as const));
const fonts = { title: shots.fonts?.title ?? 'Lilita One', body: shots.fonts?.body ?? 'Montserrat' }; const style = shotsStyle(config);
const sf = shotFonts(shots, style);
const fonts = { title: sf.title, body: sf.body };
const tpl = import.meta.dir; const tpl = import.meta.dir;
const server = Bun.serve({ const server = Bun.serve({
@ -64,21 +69,21 @@ export async function run(ctx: Ctx): Promise<void> {
const { scenes, copy } = jobs.get(url.searchParams.get('v') ?? '') ?? jobs.values().next().value!; const { scenes, copy } = jobs.get(url.searchParams.get('v') ?? '') ?? jobs.values().next().value!;
const text = copyText(lang, copy[lang]); const text = copyText(lang, copy[lang]);
const script = scriptFont(lang, text); const script = scriptFont(lang, text);
const base = { phone, tablet, rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name, const base = { style, phone, tablet, rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name,
fonts: { ...fonts, script: script ? `Store ${script.slug}` : null } }; fonts: { ...fonts, script: script ? `Store ${script.slug}` : null } };
if (url.searchParams.has('feature')) if (url.searchParams.has('feature'))
return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src, palette: palette(shots.feature?.palette ?? scenes[0]!.palette) }); return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src, palette: palette(shots.feature?.palette ?? scenes[0]!.palette, style) });
const sid = url.searchParams.get('s') ?? ''; const sid = url.searchParams.get('s') ?? '';
const scene = scenes.find(s => s.id === sid)!; const scene = scenes.find(s => s.id === sid)!;
const number = scenes.filter(s => !s.hero).indexOf(scene) + 1; const number = scenes.filter(s => !s.hero).indexOf(scene) + 1;
return Response.json({ ...base, scene, number, palette: palette(scene.palette), copy: copy[lang]?.[sid] ?? {} }); return Response.json({ ...base, scene, number, palette: palette(scene.palette, style), copy: copy[lang]?.[sid] ?? {} });
} }
let file = ''; let file = '';
if (p === '/logo' && shots.logo) file = resolve(shots.logo); if (p === '/logo' && shots.logo) file = resolve(shots.logo);
else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8)); else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8));
else if (p.startsWith('/fonts/')) file = join(fontsDir, p.slice(7)); else if (p.startsWith('/fonts/')) file = join(fontsDir, p.slice(7));
else if (p.startsWith('/out/')) file = join(outDir, p.slice(5)); else if (p.startsWith('/out/')) file = join(outDir, p.slice(5));
else if (/^\/(template|feature)\.html$/.test(p)) file = join(tpl, p.slice(1)); else if (/^\/(template|feature)(-app)?\.html$/.test(p)) file = join(tpl, p.slice(1));
if (!file || file.includes('..')) return new Response('no', { status: 404 }); if (!file || file.includes('..')) return new Response('no', { status: 404 });
const f = Bun.file(file); const f = Bun.file(file);
if (!(await f.exists())) return new Response('missing ' + p, { status: 404 }); if (!(await f.exists())) return new Response('missing ' + p, { status: 404 });
@ -123,14 +128,14 @@ export async function run(ctx: Ctx): Promise<void> {
const s = SIZES[size]!; const s = SIZES[size]!;
if (s.feature) { if (s.feature) {
const f = join(dir, 'feature', '1024x500.png'); const f = join(dir, 'feature', '1024x500.png');
await shoot(`${base}/feature.html?lang=${encodeURIComponent(lang)}${vq}`, s.w, s.h, f); await shoot(`${base}/${style === 'app' ? 'feature-app' : 'feature'}.html?lang=${encodeURIComponent(lang)}${vq}`, s.w, s.h, f);
rows.push({ size, files: [f] }); rows.push({ size, files: [f] });
continue; continue;
} }
const files: string[] = []; const files: string[] = [];
for (const [i, id] of sceneIds.entries()) { for (const [i, id] of sceneIds.entries()) {
const f = join(dir, size, `${String(i + 1).padStart(2, '0')}.png`); const f = join(dir, size, `${String(i + 1).padStart(2, '0')}.png`);
await shoot(`${base}/template.html?s=${encodeURIComponent(id)}&w=${s.w}&h=${s.h}&lang=${encodeURIComponent(lang)}${s.device ? '&device=tablet' : ''}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, s.w, s.h, f); await shoot(`${base}/${style === 'app' ? 'template-app' : 'template'}.html?s=${encodeURIComponent(id)}&w=${s.w}&h=${s.h}&lang=${encodeURIComponent(lang)}${s.device ? '&device=tablet' : ''}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, s.w, s.h, f);
files.push(f); files.push(f);
} }
rows.push({ size, files }); rows.push({ size, files });

182
cli/shots/template-app.html Normal file
View file

@ -0,0 +1,182 @@
<!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.
-->
<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(--u) * 7); 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: 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%; }
.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';
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 = W / 100, Hu = H / u;
const dev = tablet ? job.tablet : job.phone;
const FR = `/frames/${encodeURIComponent(LANG)}/${tablet ? 'tablet' : 'phone'}/`;
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 dark = lum(P.bg[1]) < .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));
R.setProperty('--bg', P.bg[0] === P.bg[2] && P.bg[1] === P.bg[0] ? P.bg[0] : `linear-gradient(180deg, ${P.bg[0]} 0%, ${P.bg[1]} 55%, ${P.bg[2]} 100%)`);
const T = job.fonts.title, B = job.fonts.body, X = job.fonts.script;
R.setProperty('--tf', X ? `'${X}', '${T}', sans-serif` : `'${T}', sans-serif`);
R.setProperty('--bf', X ? `'${X}', '${B}', sans-serif` : `'${B}', sans-serif`);
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');
const tall = Hu > 200, wide = Hu < 150;
R.setProperty('--top', tall ? 9.5 : wide ? 4.5 : 6);
R.setProperty('--fs', tall ? 8.6 : wide ? 5.6 : 7);
R.setProperty('--sub', 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 = W - 12 * u, 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;
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.ready = '1';
</script>
</body>
</html>

View file

@ -1,4 +1,4 @@
// App preview (App Store) and promo video (Play, via YouTube) from gameplay clips, with ffmpeg. // App preview (App Store) and promo video (Play, via YouTube) from app or gameplay clips, with ffmpeg.
// storebound shots video [--lang en,tr] [--target iphone,ipad,youtube] [--seconds 25] [--no-captions] [--dry] // storebound shots video [--lang en,tr] [--target iphone,ipad,youtube] [--seconds 25] [--no-captions] [--dry]
// Clips: <shots.video.clips>/<lang>/*.mp4|mov|webm (or <clips>/*.* shared by every language), joined in name order. // Clips: <shots.video.clips>/<lang>/*.mp4|mov|webm (or <clips>/*.* shared by every language), joined in name order.
// Record them with `storebound shots record` (simulator, emulator or web build) or bring your own. // Record them with `storebound shots record` (simulator, emulator or web build) or bring your own.
@ -15,7 +15,8 @@ import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { need } from '../lib/ctx';
import { done, log, warn } from '../lib/log'; import { done, log, warn } from '../lib/log';
import { launch } from './browser'; import { launch } from './browser';
import { captionsFor, type CopyFile, copyText, palette, renderDir, scriptFont } from './lib'; import { shotsStyle } from '../lib/config';
import { captionsFor, type CopyFile, copyText, palette, renderDir, scriptFont, shotFonts } from './lib';
import { ffprobe } from './report'; import { ffprobe } from './report';
import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from './spec'; import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from './spec';
@ -154,9 +155,10 @@ export async function run(ctx: Ctx): Promise<void> {
const lang = url.searchParams.get('lang') ?? ''; const lang = url.searchParams.get('lang') ?? '';
const texts = captionsFor(copy[lang], sceneIds, 64); const texts = captionsFor(copy[lang], sceneIds, 64);
const script = scriptFont(lang, copyText(lang, copy[lang])); const script = scriptFont(lang, copyText(lang, copy[lang]));
const pal = palette(shots.scenes[0]?.palette); const style = shotsStyle(ctx.loaded.config);
return Response.json({ text: texts[Number(url.searchParams.get('i'))] ?? '', ink: pal.ink, rtl: rtl.get(lang), const pal = palette(shots.scenes[0]?.palette, style);
fonts: { title: shots.fonts?.title ?? 'Lilita One', script: script ? `Store ${script.slug}` : null } }); return Response.json({ text: texts[Number(url.searchParams.get('i'))] ?? '', ink: style === 'app' ? '#111827' : pal.ink, rtl: rtl.get(lang),
fonts: { title: shotFonts(shots, style).title, script: script ? `Store ${script.slug}` : null } });
} }
if (url.pathname === '/caption.html') return new Response(Bun.file(join(import.meta.dir, 'caption.html')), { headers: { 'content-type': 'text/html; charset=utf-8' } }); if (url.pathname === '/caption.html') return new Response(Bun.file(join(import.meta.dir, 'caption.html')), { headers: { 'content-type': 'text/html; charset=utf-8' } });
if (url.pathname.startsWith('/fonts/') && !url.pathname.includes('..')) { if (url.pathname.startsWith('/fonts/') && !url.pathname.includes('..')) {

BIN
docs/img/app-style.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

5
examples/demo-app/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Generated by storebound (re-create with the commands in README.md)
.storebound/
store/shots/frames/
store/renders/
store/video/

View file

@ -0,0 +1,19 @@
# Tidy Lists (demo app)
A tiny fictional list app made only to show storebound's **app profile** and the **app screenshot style** (calm
gradient, a clean device frame, headline + subline, no sparkles or badges). One static page with sample data and the
switches a real app needs for store captures: `lang` and `screen` (`lists`, `list`, `share`, `account`). The ids and
URLs in `storebound.config.ts` are placeholders; nothing here talks to a store.
From this folder (needs Bun, ffmpeg, and Playwright's Chromium or Google Chrome):
```sh
bun serve.ts & # the app on http://127.0.0.1:8741
bun ../../cli/index.ts shots capture # raw frames: 2 languages x phone + tablet
bun ../../cli/index.ts shots fonts # Inter subset for the copy
bun ../../cli/index.ts shots render --size ios69,play,feature
bun ../../cli/index.ts shots gallery # store/renders/index.html
bun ../../cli/index.ts check # profile checks: delete-account URL, demo account, terms URL
```
Everything it writes (`store/shots/frames`, `store/renders`, `.storebound`) is gitignored.

View file

@ -0,0 +1,18 @@
#!/usr/bin/env bun
// Static server for the demo app's web page. bun examples/demo-app/serve.ts [--port 8741]
const i = process.argv.indexOf('--port');
const port = Number(i > 0 ? process.argv[i + 1] : process.env.PORT ?? 8741);
const root = new URL('./web/', import.meta.url).pathname;
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', css: 'text/css' };
const server = Bun.serve({
port, hostname: '127.0.0.1',
async fetch(req) {
let p = decodeURIComponent(new URL(req.url).pathname);
if (p.endsWith('/')) p += 'index.html';
if (p.includes('..')) return new Response('no', { status: 400 });
const f = Bun.file(root + p.slice(1));
if (!(await f.exists())) return new Response('not found', { status: 404 });
return new Response(f, { headers: { 'content-type': TYPES[p.split('.').pop() ?? ''] ?? 'application/octet-stream', 'cache-control': 'no-store' } });
},
});
console.log(`Tidy Lists demo on http://127.0.0.1:${server.port}/`);

View file

@ -0,0 +1,14 @@
{
"en": {
"lists": { "eyebrow": "Tidy Lists", "title": ["Every list,", "in one calm place"], "sub": "Groceries, trips and projects, synced on all your devices." },
"check": { "eyebrow": "Check it off", "title": ["See what is left", "at a glance"], "sub": "Tap to tick, swipe to reorder, done items fade away." },
"share": { "eyebrow": "Shared lists", "title": ["Plan together,", "shop once"], "sub": "Invite family or friends by link and edit the same list live." },
"account": { "eyebrow": "Your data", "title": ["Private by default,", "yours to export"], "sub": "Export everything or delete your account in Settings, any time." }
},
"tr": {
"lists": { "eyebrow": "Tidy Lists", "title": ["Tüm listelerin,", "tek sakin bir yerde"], "sub": "Market, gezi ve projeler; tüm cihazlarında eşitlenir." },
"check": { "eyebrow": "İşaretle", "title": ["Kalanları", "bir bakışta gör"], "sub": "Dokun işaretle, sürükle sırala, bitenler sessizce solar." },
"share": { "eyebrow": "Paylaşılan listeler", "title": ["Birlikte planla,", "bir kez alışveriş yap"], "sub": "Aileni ya da arkadaşlarını bağlantıyla davet et, aynı listeyi canlı düzenleyin." },
"account": { "eyebrow": "Verilerin", "title": ["Varsayılan olarak gizli,", "dışa aktarması senin"], "sub": "Her şeyi dışa aktar ya da hesabını Ayarlar'dan istediğin an sil." }
}
}

View file

@ -0,0 +1,51 @@
// storebound config of the demo app "Tidy Lists" (fictional, made for this repo) to show the app profile and the app
// screenshot style. Placeholder ids and URLs only: nothing here points at a real store record. See README.md.
import type { ShipConfig } from '../../cli/lib/config';
const config: ShipConfig = {
app: {
name: 'Tidy Lists',
profile: 'app',
copyright: '2026 Example Studio',
privacyPolicyUrl: 'https://example.com/privacy',
termsUrl: 'https://example.com/terms',
supportUrl: 'https://example.com/support',
website: 'https://example.com',
contactEmail: 'support@example.com',
},
accounts: { enabled: true, deletionUrl: 'https://example.com/delete-account', inApp: 'Settings > Account > Delete account' },
reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account; the Groceries list is shared with a second demo user.' },
languages: [
{ id: 'en', play: 'en-US', apple: 'en-US' },
{ id: 'tr', play: 'tr-TR', apple: 'tr' },
],
play: { packageName: 'com.example.tidylists' },
apple: { bundleId: 'com.example.tidylists' },
renders: 'store/renders',
shots: {
style: 'app',
copy: 'store/shots/copy.json',
frames: 'store/shots/frames',
out: 'store/renders',
scenes: [
{ id: 'lists', palette: 'mist', frames: [{ src: 'lists.png' }] },
{ id: 'check', palette: 'mint', frames: [{ src: 'list.png' }] },
{ id: 'share', palette: 'sky', frames: [{ src: 'share.png' }] },
{ id: 'account', palette: 'slate', frames: [{ src: 'account.png' }] },
],
feature: { frame: 'lists.png', palette: 'mist' },
capture: {
source: 'web',
baseUrl: 'http://127.0.0.1:8741',
query: 'lang={lang}',
shots: {
lists: { path: '/?screen=lists', waitFor: 'window.app && app.ready', delayMs: 100, device: 'both' },
list: { path: '/?screen=list', waitFor: 'window.app && app.ready', delayMs: 100, device: 'both' },
share: { path: '/?screen=share', waitFor: 'window.app && app.ready', delayMs: 100, device: 'both' },
account: { path: '/?screen=account', waitFor: 'window.app && app.ready', delayMs: 100, device: 'both' },
},
},
},
};
export default config;

View file

@ -0,0 +1,85 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Tidy Lists (storebound demo)</title>
<!--
A tiny fictional list app, made only to show storebound's app screenshot style. Sample data only, no network.
Switches for store captures: ?screen=lists|list|share|account&lang=en|tr (window.app.ready turns true when drawn).
-->
<style>
:root { --bg: #f6f7fb; --card: #ffffff; --ink: #111827; --muted: #6b7280; --line: #e5e7eb; --accent: #4f46e5; --ok: #059669; }
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--bg); color: var(--ink); font: 16px/1.4 -apple-system, 'Segoe UI', Roboto, Inter, sans-serif; }
header { padding: 18px 20px 10px; background: var(--bg); }
header small { color: var(--muted); font-size: 13px; font-weight: 600; letter-spacing: .02em; }
h1 { font-size: 30px; font-weight: 750; letter-spacing: -.02em; margin-top: 2px; }
main { padding: 6px 16px 90px; display: grid; gap: 12px; }
.card { background: var(--card); border-radius: 18px; padding: 14px 16px; box-shadow: 0 1px 2px rgba(17,24,39,.06); }
.row { display: flex; align-items: center; gap: 12px; }
.dot { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; color: #fff; font-weight: 700; flex: none; }
.grow { flex: 1; min-width: 0; }
.t { font-weight: 650; }
.m { color: var(--muted); font-size: 13px; }
.bar { height: 6px; border-radius: 6px; background: var(--line); margin-top: 8px; overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--accent); border-radius: 6px; }
.check { width: 24px; height: 24px; border-radius: 50%; border: 2px solid #c7cbd4; flex: none; display: grid; place-items: center; }
.check.on { background: var(--ok); border-color: var(--ok); }
.check.on::after { content: ""; width: 10px; height: 6px; border: 2.5px solid #fff; border-top: 0; border-right: 0; transform: rotate(-45deg) translate(1px, -1px); }
.done .t { color: var(--muted); text-decoration: line-through; }
.item { padding: 13px 0; border-bottom: 1px solid var(--line); }
.item:last-child { border-bottom: 0; }
.av { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; color: #fff; flex: none; }
.pill { font-size: 12px; font-weight: 650; padding: 4px 10px; border-radius: 999px; background: #eef2ff; color: var(--accent); }
.btn { display: block; text-align: center; padding: 14px; border-radius: 14px; font-weight: 650; background: var(--accent); color: #fff; }
.danger { color: #dc2626; font-weight: 650; }
nav { position: fixed; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-around; padding: 10px 0 26px; background: rgba(255,255,255,.94); border-top: 1px solid var(--line); }
nav span { font-size: 11px; color: var(--muted); font-weight: 600; display: grid; justify-items: center; gap: 4px; }
nav span b { width: 22px; height: 22px; border-radius: 7px; background: #d1d5db; }
nav span.on { color: var(--accent); } nav span.on b { background: var(--accent); }
.fab { position: fixed; right: 20px; bottom: 96px; width: 58px; height: 58px; border-radius: 18px; background: var(--accent); color: #fff; font-size: 32px; display: grid; place-items: center; box-shadow: 0 8px 20px rgba(79,70,229,.35); }
</style>
</head>
<body>
<div id="app"></div>
<script>
const q = new URLSearchParams(location.search);
const lang = q.get('lang') === 'tr' ? 'tr' : 'en';
const screen = q.get('screen') || 'lists';
document.documentElement.lang = lang;
const T = {
en: { today: 'Thursday, 9 October', lists: 'My lists', groceries: 'Groceries', trip: 'Weekend trip', home: 'Home projects', reading: 'Reading',
items: n => `${n} items`, shared: 'Shared', milk: 'Oat milk', bread: 'Sourdough bread', apples: 'Apples', coffee: 'Coffee beans', pasta: 'Pasta', basil: 'Fresh basil',
left: '3 of 6 left', share: 'Share list', people: 'People on this list', owner: 'Owner', canEdit: 'Can edit', invite: 'Invite by link',
account: 'Account', signedIn: 'Signed in as', sync: 'Sync across devices', on: 'On', export: 'Export my data', privacy: 'Privacy policy', terms: 'Terms of use', del: 'Delete account',
tabs: ['Lists', 'Today', 'Shared', 'Settings'] },
tr: { today: '9 Ekim Perşembe', lists: 'Listelerim', groceries: 'Market', trip: 'Hafta sonu gezisi', home: 'Ev işleri', reading: 'Okuma',
items: n => `${n} öğe`, shared: 'Paylaşılan', milk: 'Yulaf sütü', bread: 'Ekşi maya ekmek', apples: 'Elma', coffee: 'Kahve çekirdeği', pasta: 'Makarna', basil: 'Taze fesleğen',
left: '6 öğeden 3 kaldı', share: 'Listeyi paylaş', people: 'Bu listedeki kişiler', owner: 'Sahip', canEdit: 'Düzenleyebilir', invite: 'Bağlantıyla davet et',
account: 'Hesap', signedIn: 'Giriş yapan', sync: 'Cihazlar arası eşitleme', on: 'Açık', export: 'Verilerimi dışa aktar', privacy: 'Gizlilik politikası', terms: 'Kullanım koşulları', del: 'Hesabı sil',
tabs: ['Listeler', 'Bugün', 'Paylaşılan', 'Ayarlar'] },
}[lang];
const nav = on => `<nav>${T.tabs.map((t, i) => `<span class="${i === on ? 'on' : ''}"><b></b>${t}</span>`).join('')}</nav>`;
const list = (c, name, n, pct, extra = '') => `<div class="card"><div class="row"><div class="dot" style="background:${c}">${name[0]}</div><div class="grow"><div class="t">${name}</div><div class="m">${T.items(n)}${extra}</div><div class="bar"><i style="width:${pct}%"></i></div></div></div></div>`;
const item = (on, t, m = '') => `<div class="item row ${on ? 'done' : ''}"><div class="check ${on ? 'on' : ''}"></div><div class="grow"><div class="t">${t}</div>${m ? `<div class="m">${m}</div>` : ''}</div></div>`;
const S = {
lists: () => `<header><small>${T.today}</small><h1>${T.lists}</h1></header><main>
${list('#4f46e5', T.groceries, 6, 50, ` · <span class="pill">${T.shared}</span>`)}${list('#0ea5e9', T.trip, 9, 20)}${list('#059669', T.home, 5, 80)}${list('#f59e0b', T.reading, 12, 35)}
</main><div class="fab">+</div>${nav(0)}`,
list: () => `<header><small>${T.left}</small><h1>${T.groceries}</h1></header><main><div class="card">
${item(true, T.milk)}${item(true, T.bread)}${item(false, T.apples, '1 kg')}${item(true, T.coffee)}${item(false, T.pasta)}${item(false, T.basil)}
</div></main>${nav(0)}`,
share: () => `<header><small>${T.groceries}</small><h1>${T.share}</h1></header><main><div class="card"><div class="m" style="margin-bottom:6px">${T.people}</div>
${[['#4f46e5', 'Alex Kim', T.owner], ['#db2777', 'Sam Rivera', T.canEdit], ['#0d9488', 'Deniz Ay', T.canEdit]].map(([c, n, r]) => `<div class="item row"><div class="av" style="background:${c}">${n[0]}</div><div class="grow"><div class="t">${n}</div><div class="m">${r}</div></div></div>`).join('')}
</div><div class="btn">${T.invite}</div></main>${nav(2)}`,
account: () => `<header><small>${T.signedIn} demo@example.com</small><h1>${T.account}</h1></header><main><div class="card">
<div class="item row"><div class="grow t">${T.sync}</div><span class="pill">${T.on}</span></div><div class="item row"><div class="grow t">${T.export}</div></div>
<div class="item row"><div class="grow t">${T.privacy}</div></div><div class="item row"><div class="grow t">${T.terms}</div></div></div>
<div class="card"><div class="danger">${T.del}</div></div></main>${nav(3)}`,
};
document.getElementById('app').innerHTML = (S[screen] || S.lists)();
window.app = { ready: true };
</script>
</body>
</html>