Feature graphic fits the phone: shots.feature.fit 'contain' (default) sizes the device so its rotated box stays inside 1024x500 with a 24 px margin in both templates, 'bleed' keeps the crop, scale 0.5-1.5, config validated; shots render records measured device boxes (report.json devices) and shots check fails a feature graphic whose device leaves the canvas (warns for a screenshot's front device off a side or the top); tests/feature-fit.test.ts (rotated-box geometry, both templates rendered at 1024x500, bleeding fixture) (pitfall 55)

This commit is contained in:
CRTkafa 2026-10-13 09:49:53 +08:00
parent 6e2dbf03cc
commit 19d34286d5
15 changed files with 239 additions and 12 deletions

View file

@ -2,6 +2,13 @@
## Unreleased
- **Feature graphic fits the phone.** The Play feature graphic (1024 x 500) cut the phone off at the bottom (the game
template's 7 deg phone spanned about y 24..552). `shots.feature.fit` 'contain' (default) sizes the device so its
rotated outline stays inside with a 24 px margin, in both templates; 'bleed' keeps the old crop on purpose;
`shots.feature.scale` (0.5-1.5) resizes it; the config is validated. `shots render` records each device's measured
box in report.json and `shots check` fails a feature graphic whose device leaves the canvas (warns for a
screenshot's front device off a side or the top). Pitfall 55.
- `apple build` exports each build into `build/ios/export/<version>-<build>/` (cleaned first), runs xcodebuild with
Apple's tool folders first on PATH (a Homebrew rsync broke Xcode's IPA step: "exportArchive Copy failed"), shows the
IDEDistribution log's real error lines and exits non-zero on any export failure (pitfalls 53, 54).

View file

@ -153,7 +153,11 @@ export interface ShotsConfig {
* ['Noto Sans Display'] or a local file); a render that needs an automatic fallback is an ERROR in `shots check`. */
fonts?: { title?: FontSpec; body?: FontSpec; fallback?: FontSpec[] };
scenes: ShotScene[];
feature?: { frame: string; palette?: string | Palette };
/** Play feature graphic (1024 x 500): `frame` in the phone, `palette`. `fit` 'contain' (default) sizes the phone so
* its rotated outline stays inside the canvas with a 24 px margin; 'bleed' keeps the larger phone cropped by the
* bottom edge on purpose (shots check then allows it). `scale` (0.5-1.5, default 1) multiplies the phone's width
* (still clamped to the canvas with 'contain'). */
feature?: { frame: string; palette?: string | Palette; fit?: 'contain' | 'bleed'; scale?: number };
/** Phone screen size of the raw frames in CSS px (default 440 x 902 @3x, iPhone 6.9" minus the 54 px status bar).
* `bar` = status bar height the template draws above the frame (and that capture crops off). */
phone?: { w: number; h: number; dpr?: number; bar?: number };
@ -1133,6 +1137,11 @@ export function validateConfig(c: unknown): string[] {
for (const k of ['copy', 'frames', 'out'] as const) if (!s[k]) e.push(`shots.${k}: missing`);
if (s.style !== undefined && s.style !== 'game' && s.style !== 'app') e.push('shots.style: game | app');
if (!s.scenes?.length) e.push('shots.scenes: at least one scene');
if (s.feature !== undefined) {
if (!s.feature || typeof s.feature.frame !== 'string' || !s.feature.frame) e.push('shots.feature.frame: a frame name');
if (s.feature?.fit !== undefined && s.feature.fit !== 'contain' && s.feature.fit !== 'bleed') e.push("shots.feature.fit: 'contain' | 'bleed'");
if (s.feature?.scale !== undefined && !(typeof s.feature.scale === 'number' && s.feature.scale >= 0.5 && s.feature.scale <= 1.5)) e.push('shots.feature.scale: a number from 0.5 to 1.5');
}
const fontKeys: [string, unknown][] = [['title', s.fonts?.title], ['body', s.fonts?.body], ...(Array.isArray(s.fonts?.fallback) ? s.fonts!.fallback! : s.fonts?.fallback !== undefined ? [s.fonts.fallback] : []).map((f, i) => [`fallback[${i}]`, f] as [string, unknown])];
if (s.fonts?.fallback !== undefined && !Array.isArray(s.fonts.fallback)) e.push('shots.fonts.fallback: a list');
for (const [k, f] of fontKeys) {

File diff suppressed because one or more lines are too long

View file

@ -41,12 +41,18 @@ 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');
// device place and size from featureLayout() (lib.ts): fit 'contain' keeps the rotated phone inside the canvas
const D = job.device, tel = c.querySelector('.tel');
if (D) Object.assign(tel.style, { left: D.left + 'px', top: D.top + 'px', width: D.width + 'px', transform: `rotate(${D.angle}deg)` });
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(() => {})));
// read by `shots render` -> report.json -> shots check: the phone's rotated outline must stay inside the canvas
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
if (D?.fit === 'bleed') document.body.dataset.bleed = '1';
document.body.dataset.ready = '1';
</script>
</body>

View file

@ -49,6 +49,9 @@ 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');
// device place and size from featureLayout() (lib.ts): fit 'contain' keeps the rotated phone inside the canvas
const D = job.device, tel = c.querySelector('.tel');
if (D) Object.assign(tel.style, { left: D.left + 'px', top: D.top + 'px', width: D.width + 'px', transform: `rotate(${D.angle}deg)` });
const src = `/frames/${encodeURIComponent(lang)}/phone/${encodeURIComponent(job.frame)}`;
document.getElementById('f').src = src;
document.getElementById('bar').style.backgroundImage = `url('${src}')`;
@ -58,6 +61,9 @@ for (let i = 0; i < 22; i++)
c.insertAdjacentHTML('beforeend', `<i class="spark" style="left:${rnd() * 1024}px;top:${rnd() * 500}px;--s:${8 + rnd() * 18}px;--o:${.5 + rnd() * .5}"></i>`);
await document.fonts.ready;
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
// read by `shots render` -> report.json -> shots check: the phone's rotated outline must stay inside the canvas
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
if (D?.fit === 'bleed') document.body.dataset.bleed = '1';
document.body.dataset.ready = '1';
</script>
</body>

View file

@ -195,3 +195,54 @@ export function variantRoot(renders: string, variant: string | undefined, varian
if (!variants?.[variant]) throw new Error(`--variant ${variant}: not in shots.variants (${Object.keys(variants ?? {}).join(', ') || 'none configured'})`);
return `${renders.replace(/\/$/, '')}/${variant}`;
}
/** Axis-aligned bounding box of a w x h rectangle rotated by `deg` around its centre. */
export function rotatedBox(w: number, h: number, deg: number): { w: number; h: number } {
const r = (deg * Math.PI) / 180, c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
return { w: w * c + h * s, h: w * s + h * c };
}
/** Feature graphic canvas (Play: 1024 x 500) and the device's place per template: centre x, design width, rotation,
* and the top the old fixed layout used (kept by fit 'bleed'). */
export const FEATURE_CANVAS = { w: 1024, h: 500, margin: 24 } as const;
export const FEATURE_DEVICE: Record<'game' | 'app', { cx: number; width: number; angle: number; bleedTop: number }> = {
game: { cx: 864, width: 232, angle: 7, bleedTop: 36 },
app: { cx: 805, width: 230, angle: 0, bleedTop: 40 },
};
export interface FeatureLayout { left: number; top: number; width: number; height: number; angle: number;
/** Rotated bounding box in canvas px. */
box: { x: number; y: number; w: number; h: number }; fit: 'contain' | 'bleed' }
/** Where the feature graphic's device goes. fit 'contain' (default): the device (frame aspect dev.w : dev.h + dev.bar,
* rotated by the template's angle) is sized so its rotated bounding box stays inside the canvas with `margin` on every
* side, centred vertically; `scale` (default 1) multiplies the design width before that clamp. fit 'bleed': the design
* width x scale at the old fixed top, cropped by the canvas edge on purpose. */
export function featureLayout(style: 'game' | 'app', dev: { w: number; h: number; bar: number },
o: { fit?: 'contain' | 'bleed'; scale?: number } = {}): FeatureLayout {
const d = FEATURE_DEVICE[style], C = FEATURE_CANVAS, fit = o.fit ?? 'contain';
const k = (dev.h + dev.bar) / dev.w;
let width = d.width * (o.scale ?? 1);
if (fit === 'contain') {
const unit = rotatedBox(1, k, d.angle);
width = Math.min(width, (C.h - 2 * C.margin) / unit.h, (2 * Math.min(d.cx - C.margin, C.w - C.margin - d.cx)) / unit.w);
}
const height = width * k;
const top = fit === 'contain' ? (C.h - height) / 2 : d.bleedTop;
const bb = rotatedBox(width, height, d.angle);
const cy = top + height / 2;
return { left: d.cx - width / 2, top, width, height, angle: d.angle, fit,
box: { x: d.cx - bb.w / 2, y: cy - bb.h / 2, w: bb.w, h: bb.h } };
}
export interface DeviceBox { x: number; y: number; w: number; h: number }
/** Sides of a device box that leave a W x H canvas (more than `tol` px). */
export function boxOutside(b: DeviceBox, W: number, H: number, tol = 1): string[] {
const out: string[] = [];
if (b.y < -tol) out.push(`top ${Math.round(-b.y)} px`);
if (b.y + b.h > H + tol) out.push(`bottom ${Math.round(b.y + b.h - H)} px`);
if (b.x < -tol) out.push(`left ${Math.round(-b.x)} px`);
if (b.x + b.w > W + tol) out.push(`right ${Math.round(b.x + b.w - W)} px`);
return out;
}

View file

@ -25,7 +25,7 @@ import { done, log, warn } from '../lib/log';
import { closeBrowser, launch } from './browser';
import { defaultConcurrency, pool, progressLine } from './capture-web';
import { shotsStyle } from '../lib/config';
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { type CopyFile, type DeviceBox, SIZES, featureLayout, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
import * as rt from '../lib/rt';
@ -127,7 +127,8 @@ export async function run(ctx: Ctx): Promise<void> {
const base = { style, phone, tablet, rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name,
fonts: { ...fonts, script: st.script, titleStack: st.title, bodyStack: st.body } };
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, style) });
return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src,
device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale }), palette: palette(shots.feature?.palette ?? scenes[0]!.palette, style) });
const sid = url.searchParams.get('s') ?? '';
const scene = scenes.find(s => s.id === sid)!;
const number = scenes.filter(s => !s.hero).indexOf(scene) + 1;
@ -171,10 +172,12 @@ export async function run(ctx: Ctx): Promise<void> {
await page.waitForTimeout(200);
const tmp = out + '.tmp.png';
const overflow = JSON.parse((await page.evaluate('document.body.dataset.overflow')) ?? '[]') as string[];
const devices = JSON.parse((await page.evaluate('document.body.dataset.devices')) ?? '[]') as DeviceBox[];
const bleed = (await page.evaluate('document.body.dataset.bleed')) === '1';
if (!out.endsWith('sheet.png')) {
const measured = await measureFonts(page, Object.keys(faces));
const usage = fontUsage(measured, faces);
report[relative(outDir, out)] = { overflow, missing: errs.filter(e => e.startsWith('404')), fonts: usage };
report[relative(outDir, out)] = { overflow, devices, ...(bleed ? { bleed } : {}), missing: errs.filter(e => e.startsWith('404')), fonts: usage };
for (const f of usage.fallback) warn('shots', `${relative(root, out)}: ${f.el} ${[...f.chars].join(' ')} drawn with ${f.family} (automatic fallback)`);
for (const f of usage.missing) warn('shots', `${relative(root, out)}: ${f.el} ${[...f.chars].join(' ')} drawn with a system font`);
}

View file

@ -3,13 +3,31 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { PLAY_TABLET_SIZES, SIZES } from './lib';
import { boxOutside, type DeviceBox, PLAY_TABLET_SIZES, SIZES } from './lib';
import { fontIssues, type FontUsage } from './glyphs';
import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec';
/** <renders>/report.json: path relative to the renders dir -> flags found while rendering. missing = 404s (frames);
* fonts = which face drew the text (glyphs.ts FontUsage): automatic fallbacks and system-font glyphs are errors. */
export type RenderReport = Record<string, { overflow: string[]; missing?: string[]; fonts?: FontUsage }>;
export type RenderReport = Record<string, { overflow: string[]; missing?: string[]; fonts?: FontUsage;
/** Device frames' bounding boxes (the rotated outline, from the page's layout) in image px; bleed = cropped on purpose. */
devices?: DeviceBox[]; bleed?: boolean }>;
/** Device frames that leave the image. Feature graphic: an error unless shots.feature.fit is 'bleed' (Play shows the
* phone cut off). Screenshots: the templates let the phone run off the bottom by design, so only top / left / right
* count, as a warning. */
export function deviceIssues(entry: RenderReport[string] | undefined, size: string, W: number, H: number): Issue[] {
if (!entry?.devices?.length || entry.bleed) return [];
const feature = !!SIZES[size]?.feature;
return entry.devices.flatMap((b, i) => {
const sides = boxOutside(b, W, H).filter(s => feature || !s.startsWith('bottom'));
if (!sides.length) return [];
const which = entry.devices!.length > 1 ? `device ${i + 1}` : 'the device';
return [feature
? { level: 'error' as const, code: 'device-crop', msg: `${which} is cut off by the canvas (${sides.join(', ')}): render again (shots.feature.fit 'contain' fits it) or set fit 'bleed' if the crop is intended` }
: { level: 'warn' as const, code: 'device-crop', msg: `${which} runs off the image (${sides.join(', ')}): check the scene's frame x / dy / scale / rotate` }];
});
}
export function readReport(dir: string): RenderReport {
try { return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf8')) as RenderReport; } catch { return {}; }
@ -54,6 +72,7 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string
const entry = report[rel.replace(/\.jpe?g$/i, '.png')];
for (const msg of entry?.overflow ?? []) issues.push({ level: 'warn', code: 'overflow', msg });
issues.push(...fontIssues(entry?.fonts));
issues.push(...deviceIssues(entry, size, info.w, info.h));
if (entry && !entry.fonts) issues.push({ level: 'warn', code: 'fonts', msg: 'font usage not recorded (rendered by an older storebound): run shots fonts + shots render again so missing glyphs are caught' });
out.images.push({ rel, lang, variant, size, file, info, issues });
}

View file

@ -90,7 +90,7 @@ export function imageInfo(b: Uint8Array): ImageInfo {
}
export type IssueLevel = 'error' | 'warn';
export interface Issue { level: IssueLevel; code: 'size' | 'alpha' | 'format' | 'bytes' | 'count' | 'overflow' | 'video' | 'font-fallback' | 'glyph-missing' | 'fonts'; msg: string }
export interface Issue { level: IssueLevel; code: 'size' | 'alpha' | 'format' | 'bytes' | 'count' | 'overflow' | 'video' | 'font-fallback' | 'glyph-missing' | 'device-crop' | 'fonts'; msg: string }
/** Problems with one image for a size key. */
export function checkImage(info: ImageInfo, sizeKey: string): Issue[] {

View file

@ -178,6 +178,8 @@ 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);
// 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')].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>

View file

@ -245,6 +245,8 @@ 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);
// 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')].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>

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 55 | 2026-10 | The Play feature graphic (1024 x 500) cut the phone off at the bottom: the game template's phone was 232 px wide at top 36 and rotated 7 deg, so its outline spanned about y 24..552 and the game's bottom bar was lost; the app template bled too, and `shots.feature` only took frame + palette. Nothing measured the device against the canvas. | `featureLayout` / `rotatedBox` (`cli/shots/lib.ts`) size the device so its rotated box fits the canvas with a 24 px margin (`shots.feature.fit` 'contain', the default; 'bleed' keeps the crop on purpose; `scale` 0.5-1.5), both templates use it. The templates record each device's measured layout box (`devices` in report.json) and `shots check` fails a feature graphic whose device leaves the canvas unless fit is 'bleed', and warns for a screenshot's front device off a side or the top (`deviceIssues`, `cli/shots/report.ts`). `tests/feature-fit.test.ts`. |
| 54 | 2026-10 | The second `apple build` of a Capacitor game archived fine, then `xcodebuild -exportArchive` failed with "exportArchive Copy failed" (exit 70). The IDEDistribution log had the real cause: `rsync error: syntax or usage error (code 1)` in IDEDistributionCreateIPAStep, because a Homebrew rsync 3.x, installed and linked that day by another session's release tool, came first on PATH. A tool changed a global binary on a shared machine as a side effect. | `apple build` runs xcodebuild with Xcode's and Apple's tool folders first on PATH (`xcodePath`) and prints a note when a non-Apple rsync is first (`foreignRsync`); an export failure shows the error lines of the newest IDEDistribution*.log (`distributionErrors`, `cli/lib/xcode.ts`, `cli/apple/build.ts`). `storebound doctor` flags a non-Apple rsync first on PATH. `tools/desktop-release.ts publish` calls rsync by absolute path (`/usr/bin/rsync` without `--chmod`, modes set on the server; else the Homebrew keg), never from PATH (`publishSteps`). Rule: tools never install or relink global binaries as a side effect. `tests/apple-build-export.test.ts`. |
| 53 | 2026-10 | After that failed export, `apple upload build/ios/export/App.ipa --yes` uploaded nothing: it read the stale IPA of build 1, found build 1 already VALID on App Store Connect and exited 0 ("build 1 is VALID"), so `build && upload` looked green. The export also wrote into the same folder every time. | `apple build` exports each build into its own folder `build/ios/export/<version>-<build>/` (removed first) and exits non-zero on any export failure or a missing IPA (`exportIpa`). `apple upload` fails with exit 1 when the IPA's build number is already on App Store Connect ("this ipa is build 1, which is already on App Store Connect (VALID): did the last build fail?"); only `--allow-existing` re-checks it. `--build N` picks / requires that build's IPA; the release and beta flows use `apple upload --no-wait --build ${BUILD} --allow-existing`, so a resumed flow still passes and a stale IPA never does (`existingBuild`, `exportedIpas`, `cli/apple/upload.ts`; `tests/apple-build-export.test.ts`). |
| 52 | 2026-10 | An iOS app that is live on the App Store but whose source is not on this machine could not appear in the desktop: the machine's app list (`~/.storebound/projects.json`) took only folders. | `storebound apps add --store apple\|play --app-id <id> --account <profile>` writes a store-only entry (`storeApps`, no path; `apps folders` shows "no local project", `apps remove apple:<id>`); the desktop adds it to the app list of the account with that profile (`registry::with_store_only`, `store_apps_cached` / `store_apps_refresh`), shown read-only as "Store only"; writing the folder list keeps the entries. `tests/onboard.test.ts`, `src-tauri/tests/machine.rs`, the boot check's `shared-profiles` case (zero uncaught errors). |

View file

@ -641,3 +641,11 @@ that fails without the fix (see CONTRIBUTING.md).
names the culprit. Never install or `brew link` a global tool as a side effect on a shared machine.
- **Guard:** `xcodePath`, `foreignRsync`, `distributionErrors` (`cli/lib/xcode.ts`), doctor, `publishSteps`
(`tools/desktop-release.ts`, rsync by absolute path).
## 55. The feature graphic cut the phone off at the bottom
- **Do:** nothing by default: `shots.feature.fit` 'contain' sizes the (rotated) phone so its outline stays inside the
1024 x 500 canvas with a 24 px margin. `fit: 'bleed'` keeps the larger, cropped phone on purpose; `scale` (0.5-1.5)
resizes it. `shots check` fails a feature graphic whose device leaves the canvas (measured from the page's layout
at render time, `devices` in report.json), and warns when a screenshot's front device runs off a side or the top.
- **Guard:** `featureLayout`, `rotatedBox` (`cli/shots/lib.ts`), `deviceIssues` (`cli/shots/report.ts`),
`tests/feature-fit.test.ts` (geometry, both templates rendered at 1024 x 500, a bleeding fixture).

View file

@ -118,7 +118,10 @@ the icon, feature graphic and phone screenshots and prints `tablet screenshots:
8. **Check.** `storebound shots check` (read-only, exit 1 on errors): pixel sizes (Apple: a listed size, portrait or
landscape; Play screenshots 320-3840 px at most 2:1; feature graphic exactly 1024 x 500), alpha, Play JPEG /
24-bit PNG and 8 MB, counts per set (Apple 1-10, Play 2-8), text overflow flags (warnings), glyphs drawn by an
automatic font fallback or a system font (errors; `shots render` measures every character in the page). `--variants` also checks
automatic font fallback or a system font (errors; `shots render` measures every character in the page), a device
frame cut off by the feature graphic's edge (error unless `shots.feature.fit: 'bleed'`; the default 'contain' fits
the rotated phone with a 24 px margin, `scale` 0.5-1.5 resizes it) or a screenshot's front device running off a
side or the top (warning; the bottom bleed is the design). `--variants` also checks
A/B variant folders. `storebound validate` runs the same checks on the renders folder and lists errors as
blockers next to the store state (`--no-local` skips them).
9. **Review.** `storebound shots gallery` writes `<renders>/index.html`: one self-contained page (no network, open it

110
tests/feature-fit.test.ts Normal file
View file

@ -0,0 +1,110 @@
// Regression (2026-10-13): the Play feature graphic cut the phone off at the bottom. The game template's phone was
// 232 px wide at top 36 rotated 7 deg, so its outline spanned about y 24..552 on a 500 px canvas. featureLayout()
// now sizes the phone so its rotated box fits (fit 'contain'), the templates record the measured device boxes and
// `shots check` fails a feature graphic whose device leaves the canvas unless fit is 'bleed'.
import { afterAll, describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { boxOutside, DEFAULT_PHONE, FEATURE_CANVAS, featureLayout, rotatedBox } from '../cli/shots/lib';
import { deviceIssues } from '../cli/shots/report';
import { validateConfig } from '../cli/lib/config';
const close = (a: number, b: number) => expect(Math.abs(a - b)).toBeLessThan(0.01);
describe('rotated device box', () => {
test('0 and 90 degrees', () => {
expect(rotatedBox(100, 200, 0)).toEqual({ w: 100, h: 200 });
const r = rotatedBox(100, 200, 90); close(r.w, 200); close(r.h, 100);
});
for (const [w, h] of [[232, 504], [200, 434], [120, 260], [300, 300]] as const) test(`7 deg, ${w} x ${h}`, () => {
const r = rotatedBox(w, h, 7), s = Math.sin(7 * Math.PI / 180), c = Math.cos(7 * Math.PI / 180);
close(r.w, w * c + h * s); close(r.h, w * s + h * c);
expect(rotatedBox(w, h, -7)).toEqual(r);
});
test('the old layout bleeds ~50 px; contain fits with the margin', () => {
const old = featureLayout('game', DEFAULT_PHONE, { fit: 'bleed' });
expect(old.width).toBe(232);
expect(boxOutside(old.box, 1024, 500)[0]).toMatch(/^bottom (4\d|5\d) px$/);
for (const style of ['game', 'app'] as const) for (const dev of [DEFAULT_PHONE, { w: 390, h: 790, bar: 54 }, { w: 412, h: 860, bar: 24 }, { w: 600, h: 700, bar: 20 }])
for (const scale of [0.5, 1, 1.5]) {
const L = featureLayout(style, dev, { scale });
const m = FEATURE_CANVAS.margin;
expect(L.box.y).toBeGreaterThanOrEqual(m - 0.01);
expect(L.box.y + L.box.h).toBeLessThanOrEqual(500 - m + 0.01);
expect(L.box.x).toBeGreaterThanOrEqual(m - 0.01);
expect(L.box.x + L.box.w).toBeLessThanOrEqual(1024 - m + 0.01);
}
});
});
describe('shots check: device-crop', () => {
const box = (y: number, h: number) => ({ x: 700, y, w: 280, h });
test('a bleeding feature graphic is an error, unless fit is bleed', () => {
const e = deviceIssues({ overflow: [], devices: [box(24, 528)] }, 'feature', 1024, 500);
expect(e).toHaveLength(1);
expect(e[0]!.level).toBe('error');
expect(e[0]!.msg).toContain('bottom 52 px');
expect(deviceIssues({ overflow: [], devices: [box(24, 528)], bleed: true }, 'feature', 1024, 500)).toEqual([]);
expect(deviceIssues({ overflow: [], devices: [box(24, 452)] }, 'feature', 1024, 500)).toEqual([]);
expect(deviceIssues({ overflow: [] }, 'feature', 1024, 500)).toEqual([]);
});
test('screenshots: bottom bleed is the design, a side crop warns', () => {
expect(deviceIssues({ overflow: [], devices: [{ x: 100, y: 900, w: 800, h: 1400 }] }, 'play', 1080, 1920)).toEqual([]);
const w = deviceIssues({ overflow: [], devices: [{ x: -60, y: 900, w: 800, h: 900 }] }, 'play', 1080, 1920);
expect(w.map(i => [i.level, i.code])).toEqual([['warn', 'device-crop']]);
});
});
describe('shots.feature config', () => {
test('fit and scale are validated', () => {
const errs = (feature: unknown) => validateConfig({ app: { name: 'X', profile: 'game' }, languages: [{ id: 'en' }],
shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', frames: [{ src: 'a.png' }] }], feature } } as never).filter(e => e.startsWith('shots.feature'));
expect(errs({ frame: 'a.png' })).toEqual([]);
expect(errs({ frame: 'a.png', fit: 'bleed', scale: 1.2 })).toEqual([]);
expect(errs({ frame: 'a.png', fit: 'cover' })).toEqual(["shots.feature.fit: 'contain' | 'bleed'"]);
expect(errs({ frame: 'a.png', scale: 3 })).toEqual(['shots.feature.scale: a number from 0.5 to 1.5']);
});
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let browser: any = null;
try {
const { chromium } = await import('playwright-core');
try { browser = await chromium.launch({ headless: true }); } catch { browser = await chromium.launch({ headless: true, channel: 'chrome' }); }
} catch { /* skipped below */ }
if (!browser) console.warn('feature graphic render test skipped: no Chromium could launch. Run `bunx playwright install chromium`.');
afterAll(async () => { await browser?.close(); }, 30_000);
// 1x1 PNG
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
describe.skipIf(!browser)('feature graphic templates at 1024 x 500 (DOM layout box)', () => {
for (const style of ['game', 'app'] as const) for (const fit of ['contain', 'bleed'] as const) test(`${style}, fit ${fit}`, async () => {
const page = await browser.newPage({ viewport: { width: 1024, height: 500 } });
try {
const html = readFileSync(join(import.meta.dir, '..', 'cli', 'shots', style === 'app' ? 'feature-app.html' : 'feature.html'), 'utf8');
const job = { style, phone: DEFAULT_PHONE, logo: false, appName: 'Demo', fonts: { title: 'X' }, frame: 'a.png',
device: featureLayout(style, DEFAULT_PHONE, { fit }),
palette: { bg: ['#123456', '#345678', '#567890'], glow: '#ffffff', ink: '#000000', shadow: 'rgba(0,0,0,.4)' } };
await page.route('http://t.test/**', (r: { request(): { url(): string }; fulfill(o: unknown): Promise<void> }) => {
const u = new URL(r.request().url());
if (u.pathname === '/feature.html') return r.fulfill({ contentType: 'text/html', body: html });
if (u.pathname === '/job.json') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(job) });
if (u.pathname.startsWith('/frames/')) return r.fulfill({ contentType: 'image/png', body: PNG });
return r.fulfill({ contentType: 'text/css', body: '' });
});
await page.goto('http://t.test/feature.html?lang=en');
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 });
const devices = JSON.parse(await page.evaluate('document.body.dataset.devices')) as { x: number; y: number; w: number; h: number }[];
expect(devices).toHaveLength(1);
const d = devices[0]!;
// the measured layout box matches the computed geometry
expect(Math.abs(d.y - job.device.box.y)).toBeLessThan(1.5); expect(Math.abs(d.h - job.device.box.h)).toBeLessThan(1.5);
const bleed = (await page.evaluate('document.body.dataset.bleed')) === '1';
const issues = deviceIssues({ overflow: [], devices, bleed }, 'feature', 1024, 500);
expect(issues).toEqual([]);
if (fit === 'contain') expect(boxOutside(d, 1024, 500)).toEqual([]);
else expect(bleed).toBe(true);
} finally { await page.close(); }
}, 30_000);
});