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:
parent
6e2dbf03cc
commit
19d34286d5
15 changed files with 239 additions and 12 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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, '<')}</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
110
tests/feature-fit.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue