diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ec019..e21ef8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/-/` (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). diff --git a/cli/lib/config.ts b/cli/lib/config.ts index db18512..ed2795d 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -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) { diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index 58a6ac7..27195a1 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -38,10 +38,10 @@ export const TEMPLATE_TEXT: Record = { /** The pages shots render / video load (cli/shots/*.html), by file name. */ export const SHOTS_PAGES: Record = { "caption.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", - "feature-app.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", - "feature.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", - "template-app.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", - "template.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", + "feature-app.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", + "feature.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", + "template-app.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", + "template.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", }; /** Play Console's Data safety form export (cli/data/play-data-safety-form.csv). */ diff --git a/cli/shots/feature-app.html b/cli/shots/feature-app.html index 7691c91..cf9e210 100644 --- a/cli/shots/feature-app.html +++ b/cli/shots/feature-app.html @@ -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 ? '' : `
${String(job.appName).replace(/`); 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'; diff --git a/cli/shots/feature.html b/cli/shots/feature.html index 02dc80e..0b51ac2 100644 --- a/cli/shots/feature.html +++ b/cli/shots/feature.html @@ -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', ``); 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'; diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index eb62560..3346f06 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -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; +} diff --git a/cli/shots/render.ts b/cli/shots/render.ts index a04b23e..a0eea9d 100644 --- a/cli/shots/render.ts +++ b/cli/shots/render.ts @@ -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 { 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 { 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`); } diff --git a/cli/shots/report.ts b/cli/shots/report.ts index 3d8db0f..20b0206 100644 --- a/cli/shots/report.ts +++ b/cli/shots/report.ts @@ -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'; /** /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; +export type RenderReport = Record; + +/** 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 }); } diff --git a/cli/shots/spec.ts b/cli/shots/spec.ts index a76b94f..8c8074b 100644 --- a/cli/shots/spec.ts +++ b/cli/shots/spec.ts @@ -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[] { diff --git a/cli/shots/template-app.html b/cli/shots/template-app.html index d00888e..30accd0 100644 --- a/cli/shots/template-app.html +++ b/cli/shots/template-app.html @@ -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'; diff --git a/cli/shots/template.html b/cli/shots/template.html index e904846..f9fa085 100644 --- a/cli/shots/template.html +++ b/cli/shots/template.html @@ -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'; diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 9d3e4a8..92afc4d 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -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/-/` (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 --account ` writes a store-only entry (`storeApps`, no path; `apps folders` shows "no local project", `apps remove apple:`); 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). | diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index c6fa186..855cec8 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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). diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index 4503675..9281187 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -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 `/index.html`: one self-contained page (no network, open it diff --git a/tests/feature-fit.test.ts b/tests/feature-fit.test.ts new file mode 100644 index 0000000..8bb6b5e --- /dev/null +++ b/tests/feature-fit.test.ts @@ -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 }) => { + 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); +});