diff --git a/CHANGELOG.md b/CHANGELOG.md index 04bd82a..1d65e5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased +- `shots` full-bleed layout: `shots.layout: "bleed"` fills the image with the app's own frame (no device, no background) and puts the scene title as a short corner caption (`corner`, `captionScale`, `captionWidth`, `captionInset` per scene) legible through a text shadow and soft glow only; the Play feature graphic shows the logo or `shots.feature.title`. Frames are captured per size at its own pixels in `///.png` (feature: `/feature/`), including the `mac` and `chromebook` sizes; `shots check` / `capture --source folder` verify aspect and native width. `shots.orientation: "landscape"` (bleed only) renders every portrait size landscape (stores accept it). Templates `template-bleed.html`, `feature-bleed.html`. + ## 0.8.47 · 2026-10-23 - `storebound desktop`: an update no longer reports "installed" while the old app stays. It stops the running app first (exact install path only, graceful close, then by PID), checks the installed exe's own version (Windows PE version resource instead of the registry entry; Info.plist on macOS) and fails with what it found, and prints "opened" only when a visible (not `--hidden`) process started from the installed path, otherwise it names the app's log. diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 9abb510..e6e87cc 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -131,7 +131,16 @@ export interface ShotFrame { src: string; x?: number; dy?: number; scale?: numbe /** bg: gradient stops (2-3 colours), one colour (flat) or a CSS gradient string. image: a photo background (path * relative to the config, cropped to cover) under a dark overlay (0-0.9, default 0.45) that keeps white ink legible. */ export interface Palette { bg: string | [string, string] | [string, string, string]; glow: string; ink: string; shadow: string; image?: string; overlay?: number } -export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number } +export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number + /** layout "bleed": the corner the caption sits in (top / bottom, left / right). Default "tl". */ + corner?: 'tl' | 'tr' | 'bl' | 'br'; + /** layout "bleed": caption size multiplier (0.6-1.4, default 1). */ + captionScale?: number; + /** layout "bleed": the caption's maximum width as a share of the image width (0.2-0.9; default 0.4 landscape, + * 0.84 portrait). Lines that do not fit shrink, down to 60 %. */ + captionWidth?: number; + /** layout "bleed": the caption's distance from its corner as a share of the short side (0.01-0.15, default 0.05). */ + captionInset?: number } /** A local font file for shots.fonts. license: 'OFL-1.1', 'proprietary' or a short note (shown by `shots fonts`; * `shots check` warns without one). weight / style: the @font-face descriptors (default: all weights, normal). */ @@ -144,6 +153,16 @@ export interface ShotsConfig { * device frame, headline + subline, no sparkles or badges). Default: app for the app / subscription-app profiles, * else game. */ style?: 'game' | 'app'; + /** "device" (default): the frames inside device outlines on the style's background, titles above. "bleed": the + * frame fills the whole image (no device, no background) and the scene's title is a short caption in one corner + * (scene.corner), legible through a text shadow and a soft glow only. Bleed frames are captured per size at the + * size's own pixels: ///.png (feature: //feature/); + * that includes the mac and chromebook sizes. */ + layout?: 'device' | 'bleed'; + /** "landscape" (only with layout "bleed"): every portrait screenshot size is rendered landscape (width and height + * swapped; the feature graphic, mac and chromebook are landscape anyway). Both stores accept landscape + * screenshots. Default "portrait". */ + orientation?: 'portrait' | 'landscape'; /** JSON file: { [languageId]: { [sceneId]: { eyebrow, title: [line1, line2], sub, badges: [[big, small?]] } } } */ copy: string; /** Raw frames: ///.png */ @@ -164,6 +183,8 @@ export interface ShotsConfig { * 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; + /** layout "bleed": the name on the feature graphic when there is no logo (default app.name). */ + title?: string; /** Photo background for the feature graphic (path relative to the config, or { image, overlay }); wins over the * palette's image. Also usable for the App Store Header and Search Results art. */ background?: string | { image: string; overlay?: number } }; @@ -1214,6 +1235,15 @@ export function validateConfig(c: unknown): string[] { const s = cfg.shots; 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.layout !== undefined && s.layout !== 'device' && s.layout !== 'bleed') e.push('shots.layout: device | bleed'); + if (s.orientation !== undefined && s.orientation !== 'portrait' && s.orientation !== 'landscape') e.push('shots.orientation: portrait | landscape'); + else if (s.orientation === 'landscape' && s.layout !== 'bleed') e.push("shots.orientation: 'landscape' needs shots.layout: 'bleed' (the device layouts draw portrait phones)"); + for (const sc of s.scenes ?? []) { + if (sc.corner !== undefined && !['tl', 'tr', 'bl', 'br'].includes(sc.corner)) e.push(`shots.scenes ${sc.id}: corner tl | tr | bl | br`); + if (sc.captionScale !== undefined && !(sc.captionScale >= 0.6 && sc.captionScale <= 1.4)) e.push(`shots.scenes ${sc.id}: captionScale 0.6-1.4`); + if (sc.captionWidth !== undefined && !(sc.captionWidth >= 0.2 && sc.captionWidth <= 0.9)) e.push(`shots.scenes ${sc.id}: captionWidth 0.2-0.9`); + if (sc.captionInset !== undefined && !(sc.captionInset >= 0.01 && sc.captionInset <= 0.15)) e.push(`shots.scenes ${sc.id}: captionInset 0.01-0.15`); + } if (!s.scenes?.length) e.push('shots.scenes: at least one scene'); if (s.landscape?.frame !== undefined && s.landscape.frame !== 'window' && s.landscape.frame !== 'none') e.push("shots.landscape.frame: 'window' | 'none'"); if (s.landscape?.side !== undefined && s.landscape.side !== 'left' && s.landscape.side !== 'right') e.push("shots.landscape.side: 'left' | 'right'"); diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index f5917f9..f392f2f 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -39,8 +39,10 @@ export const TEMPLATE_TEXT: Record = { 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-bleed.html": "\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-bleed.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", }; diff --git a/cli/shots/capture.ts b/cli/shots/capture.ts index b82d0e3..1267e7c 100644 --- a/cli/shots/capture.ts +++ b/cli/shots/capture.ts @@ -114,7 +114,7 @@ export async function run(ctx: Ctx): Promise { const dir = resolve(cap.folder ?? shots.frames); const issues = checkFrameFolder(config, dir, langs); for (const x of issues) warn('shots', `frames/${x.file}: ${x.msg}`); - if (issues.length) throw new Error(`${issues.length} frame problems in ${dir} (expected //.png)`); + if (issues.length) throw new Error(`${issues.length} frame problems in ${dir} (expected /${shots.layout === 'bleed' ? '' : ''}/.png)`); if (dir !== framesDir) warn('shots', `frames are read from shots.frames (${shots.frames}); point it at ${cap.folder} or copy them there`); return done('shots', `folder frames ok: ${dir}`); } diff --git a/cli/shots/check.ts b/cli/shots/check.ts index d7f8f85..7f322ee 100644 --- a/cli/shots/check.ts +++ b/cli/shots/check.ts @@ -15,7 +15,7 @@ import type { ShipConfig } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { log, printJson } from '../lib/log'; import { checkFrames, type FrameIssue } from './devices'; -import { defaultSizes, frameDims, SIZES } from './lib'; +import { defaultSizes, frameDims, renderDims, SIZES } from './lib'; import type { LocalIssue, RendersCheck } from '../lib/validate-rules'; import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders, storeOf } from './report'; import { imageInfo } from './spec'; @@ -65,7 +65,29 @@ export function framesNeeded(config: ShipConfig, langs: string[], tablets?: bool return langs.flatMap(lang => devices.flatMap(device => (device === 'phone' ? phone : scene).map(name => ({ lang, device, name })))); } +/** shots.layout 'bleed': one frame per scene for every size, at that size's pixels (//; the mac and + * chromebook sizes too), and the feature frame in /feature/. Each must have the size's aspect (1 %) and at least + * its width (no upscaling). */ +export function checkBleedFrames(config: ShipConfig, dir: string, langs: string[], tablets?: boolean): FrameIssue[] { + const shots = config.shots!; + const out: FrameIssue[] = []; + const names = [...new Set(shots.scenes.map(s => s.frames[0]!.src))]; + for (const lang of langs) for (const size of defaultSizes(config, tablets)) { + const feature = !!SIZES[size]!.feature; + const want = renderDims(size, shots); + for (const name of feature ? (shots.feature?.frame ? [shots.feature.frame] : []) : names) { + const rel = `${lang}/${feature ? 'feature' : size}/${name}`, f = join(dir, rel); + if (!existsSync(f)) { out.push({ file: rel, msg: 'missing' }); continue; } + const i = imageInfo(readFileSync(f)); + if (Math.abs((i.w / i.h) / (want.w / want.h) - 1) > 0.01) out.push({ file: rel, msg: `${i.w}x${i.h} has aspect ${(i.w / i.h).toFixed(3)}, ${size} wants ${(want.w / want.h).toFixed(3)} (${want.w}x${want.h}): capture at that size` }); + else if (i.w < want.w * 0.99) out.push({ file: rel, msg: `${i.w}x${i.h} is smaller than ${want.w}x${want.h}: capture at native resolution (no upscaling)` }); + } + } + return out; +} + export function checkFrameFolder(config: ShipConfig, dir: string, langs: string[], tablets?: boolean): FrameIssue[] { + if (config.shots?.layout === 'bleed') return checkBleedFrames(config, dir, langs, tablets); const found = new Map(); for (const lang of langs) for (const device of ['phone', 'tablet']) { const d = join(dir, lang, device); diff --git a/cli/shots/feature-bleed.html b/cli/shots/feature-bleed.html new file mode 100644 index 0000000..848715d --- /dev/null +++ b/cli/shots/feature-bleed.html @@ -0,0 +1,49 @@ + + + + + + + + + +
+ + + diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index 8f56cde..57b2b59 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -32,11 +32,13 @@ export const PLAY_TABLET_LANDSCAPE: Record = { /** Pixel size and layout of one size key for a project: the landscape layout for mac / chromebook, and for the Play * tablet slots with shots.landscape.playTablets. frame = the device drawn around a landscape frame ('window' = a Mac * window, 'none' = the bare frame with rounded corners); default window on Mac, none on Play. Pure. */ -export function renderDims(size: string, shots: Pick | undefined): { w: number; h: number; landscape: boolean; frame: 'window' | 'none' } { +export function renderDims(size: string, shots: Pick | undefined): { w: number; h: number; landscape: boolean; frame: 'window' | 'none' } { const s = SIZES[size]!; const tabLand = !!shots?.landscape?.playTablets && !!PLAY_TABLET_LANDSCAPE[size]; const landscape = s.device === 'landscape' || tabLand; - const d = tabLand ? PLAY_TABLET_LANDSCAPE[size]! : s; + // shots.orientation 'landscape' (bleed layout): every other portrait screenshot size swaps width and height + const swap = shots?.orientation === 'landscape' && !s.feature && !landscape; + const d = tabLand ? PLAY_TABLET_LANDSCAPE[size]! : swap ? { w: s.h, h: s.w } : s; const frame = shots?.landscape?.frame ?? (size === 'mac' ? 'window' : 'none'); return { w: d.w, h: d.h, landscape, frame }; } @@ -310,11 +312,16 @@ export function boxOutside(b: DeviceBox, W: number, H: number, tol = 1): string[ * loads. A missing one renders an image with no app or game content (only a 404 in the page), so `shots render` * refuses to start without them. Landscape layouts use each scene's first frame; the feature graphic a phone frame. */ export function framesForRender(scenes: { frames: { src: string }[] }[], sizes: string[], langs: string[], - shots: Pick & { feature?: { frame?: string } }): string[] { + shots: Pick & { feature?: { frame?: string } }): string[] { const out = new Set(); for (const lang of langs) for (const size of sizes) { const s = SIZES[size]; if (!s) continue; + if (shots.layout === 'bleed') { // one frame per scene at each size's own pixels: //, the feature one in /feature/ + if (s.feature) { const f = shots.feature?.frame ?? scenes[0]?.frames[0]?.src; if (f) out.add(`${lang}/feature/${f}`); } + else for (const sc of scenes) if (sc.frames[0]) out.add(`${lang}/${size}/${sc.frames[0].src}`); + continue; + } if (s.feature) { const f = shots.feature?.frame ?? scenes[0]?.frames[0]?.src; if (f) out.add(`${lang}/phone/${f}`); continue; } const d = renderDims(size, shots); const kind = d.landscape ? 'landscape' : s.device ? 'tablet' : 'phone'; diff --git a/cli/shots/render.ts b/cli/shots/render.ts index fff6517..2ff3ef1 100644 --- a/cli/shots/render.ts +++ b/cli/shots/render.ts @@ -13,6 +13,9 @@ // paths) are served from where they are at /fonts/local/... (local-fonts.ts); a file changed since `shots fonts` fails. // Style: shotsStyle() = shots.style, else app for the app / subscription-app profiles (template-app.html, // feature-app.html: calm gradient, device frame, headline + subline), else game (template.html, feature.html). +// Layout: shots.layout 'bleed' renders template-bleed.html / feature-bleed.html instead (the frame of that size fills +// the image, the title is a corner caption with a text shadow, no device or background); shots.orientation 'landscape' +// swaps every other portrait screenshot size (renderDims in lib.ts). import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { SHOTS_PAGES } from '../lib/embedded'; import { fontCodepoints } from './cmap'; @@ -91,6 +94,7 @@ export async function run(ctx: Ctx): Promise { const { phone, tablet } = frameDims(config); const rtl = new Map(config.languages.map(l => [l.id, !!l.rtl] as const)); const style = shotsStyle(config); + const bleedLayout = shots.layout === 'bleed'; const rf = resolveShotFonts(shots, style, resolve, root); const sf = { title: rf.title.family, body: rf.body.family }; const fonts = { title: sf.title, body: sf.body }; @@ -156,10 +160,10 @@ export async function run(ctx: Ctx): Promise { const st = stacks(lang, copy); const ls = shots.landscape?.size; const landscape = { w: ls?.w ?? 1440, h: ls?.h ?? 900, bar: 0 }; - const base = { style, phone, tablet, landscape, side: shots.landscape?.side ?? 'left', rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name, + const base = { style, layout: shots.layout ?? 'device', phone, tablet, landscape, side: shots.landscape?.side ?? 'left', 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, + return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src, featureTitle: shots.feature?.title, device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale }), palette: look(palette(shots.feature?.palette ?? scenes[0]!.palette, style), style === 'app' ? 135 : 160, style === 'app' ? 55 : 50, shots.feature?.background) }); const sid = url.searchParams.get('s') ?? ''; @@ -173,7 +177,7 @@ export async function run(ctx: Ctx): Promise { else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8)); else if (p.startsWith('/fonts/')) file = fontsRoute(p.slice(7), fontsDir, root, manifest) ?? ''; else if (p.startsWith('/out/')) file = join(outDir, p.slice(5)); - else if (/^\/(template|feature)(-app)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } }); + else if (/^\/(template|feature)(-app|-bleed)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } }); if (!file || file.includes('..')) return new Response('no', { status: 404 }); return rt.fileResponse(file, { 'content-type': TYPES[file.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' }) ?? new Response('missing ' + p, { status: 404 }); }, @@ -254,7 +258,7 @@ export async function run(ctx: Ctx): Promise { const s = SIZES[size]!, d = renderDims(size, shots); if (s.feature) { const f = join(dir, 'feature', '1024x500.png'); - items.push({ url: `${base}/${style === 'app' ? 'feature-app' : 'feature'}.html?lang=${encodeURIComponent(lang)}${vq}`, w: s.w, h: s.h, out: f }); + items.push({ url: `${base}/${bleedLayout ? 'feature-bleed' : style === 'app' ? 'feature-app' : 'feature'}.html?lang=${encodeURIComponent(lang)}${vq}`, w: s.w, h: s.h, out: f }); rows.push({ size, files: [f] }); continue; } @@ -262,7 +266,7 @@ export async function run(ctx: Ctx): Promise { for (const [i, id] of sceneIds.entries()) { const f = join(dir, size, `${String(i + 1).padStart(2, '0')}.png`); const dev = d.landscape ? `&device=landscape&frame=${d.frame}` : s.device ? '&device=tablet' : ''; - items.push({ url: `${base}/${style === 'app' ? 'template-app' : 'template'}.html?s=${encodeURIComponent(id)}&w=${d.w}&h=${d.h}&lang=${encodeURIComponent(lang)}${dev}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, w: d.w, h: d.h, out: f }); + items.push({ url: `${base}/${bleedLayout ? 'template-bleed' : style === 'app' ? 'template-app' : 'template'}.html?s=${encodeURIComponent(id)}&w=${d.w}&h=${d.h}&lang=${encodeURIComponent(lang)}&size=${encodeURIComponent(size)}${bleedLayout ? '' : dev}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, w: d.w, h: d.h, out: f }); files.push(f); } rows.push({ size, files, dims: { w: d.w, h: d.h } }); diff --git a/cli/shots/template-bleed.html b/cli/shots/template-bleed.html new file mode 100644 index 0000000..a94c215 --- /dev/null +++ b/cli/shots/template-bleed.html @@ -0,0 +1,97 @@ + + + + + + + + + +
+ + + diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index 80f1ac7..c2bccb1 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -93,6 +93,8 @@ Play API has no Chromebook image type, so `play images` prints the Main store li uploading), and `shots.landscape.playTablets: true` renders tab7 / tab10 in landscape (1920 x 1080 / 2560 x 1440), which Play's tablet slots accept. Badges are not drawn in landscape. +**Full-bleed layout (`shots.layout: "bleed"`).** The app's own frame fills the whole image: no device, no background; the scene's title becomes a short caption (3-5 words) in one corner (`corner` tl/tr/bl/br, `captionScale`, `captionWidth`, `captionInset`), made legible by a text shadow and soft glow only (palette `ink` = tight outline, `shadow` = wide glow, `glow` = second line); never a band or scrim behind the words. Frames are captured per size at the size's own pixels: `///.png` for every size including `mac` and `chromebook`, the feature graphic frame in `//feature/` (the feature graphic shows `shots.logo`, else `shots.feature.title`, else the app name). `shots check` and `capture --source folder` refuse a frame with the wrong aspect or fewer pixels than the size (no upscaling). `shots.orientation: "landscape"` (bleed only) swaps every portrait size to landscape (Apple takes landscape for every display set, Play any 320-3840 px up to 2:1); feature, mac and chromebook are landscape already. + **Safe margins (every template, every size, both stores).** Text and logo keep about 7 % of the width left and right and 5 % of the height at the top; the logo scales to fit inside. Each render records the layout boxes of the logo, eyebrow, title lines and subtitle (and the feature graphic's logo or name) in `report.json`, and `shots check` fails diff --git a/tests/shots-bleed.test.ts b/tests/shots-bleed.test.ts new file mode 100644 index 0000000..36b2992 --- /dev/null +++ b/tests/shots-bleed.test.ts @@ -0,0 +1,94 @@ +// shots.layout 'bleed' (the frame fills the image, a corner caption with a text shadow, no device) and +// shots.orientation 'landscape', plus the Mac App Store size (apple.mac). Pure checks, no browser. +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type ShipConfig, validateConfig } from '../cli/lib/config'; +import { SHOTS_PAGES } from '../cli/lib/embedded'; +import { checkFrameFolder } from '../cli/shots/check'; +import { renderDims } from '../cli/shots/lib'; +import { checkImage } from '../cli/shots/spec'; + +function png(w: number, h: number): Uint8Array { + const b = new Uint8Array(8 + 25 + 12); + b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const dv = new DataView(b.buffer); + dv.setUint32(8, 13); b.set([0x49, 0x48, 0x44, 0x52], 12); dv.setUint32(16, w); dv.setUint32(20, h); b[24] = 8; b[25] = 2; + dv.setUint32(33, 0); b.set([0x49, 0x45, 0x4e, 0x44], 37); + return b; +} + +const base = (shots: Partial = {}, apple: ShipConfig['apple'] = { bundleId: 'com.example.game', mac: true }): ShipConfig => ({ + app: { name: 'Example Game' }, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], + apple, play: { packageName: 'com.example.game', tablets: false }, + shots: { copy: 'c.json', frames: 'frames', out: 'out', layout: 'bleed', orientation: 'landscape', + scenes: [{ id: 'a', palette: 'night', frames: [{ src: 'a.png' }], corner: 'bl' }], feature: { frame: 'f.png' }, ...shots }, +} as ShipConfig); + +describe('landscape sizes', () => { + const dims = (k: string, o?: 'landscape' | 'portrait') => { const d = renderDims(k, { orientation: o }); return { w: d.w, h: d.h }; }; + test('portrait sizes swap, feature, mac and chromebook stay', () => { + expect(dims('ios69', 'landscape')).toEqual({ w: 2868, h: 1320 }); + expect(dims('ipad13', 'landscape')).toEqual({ w: 2752, h: 2064 }); + expect(dims('play', 'landscape')).toEqual({ w: 1920, h: 1080 }); + expect(dims('ios69')).toEqual({ w: 1320, h: 2868 }); + expect(dims('feature', 'landscape')).toEqual({ w: 1024, h: 500 }); + expect(dims('mac', 'landscape')).toEqual({ w: 2880, h: 1800 }); + expect(dims('chromebook', 'landscape')).toEqual({ w: 1920, h: 1080 }); + }); + test('stores accept the landscape renders', () => { + for (const [k, w, h] of [['ios69', 2868, 1320], ['ipad13', 2752, 2064], ['mac', 2880, 1800], ['play', 1920, 1080], ['tab7', 1920, 1200], ['tab10', 2560, 1600]] as const) + expect(checkImage({ format: 'png', w, h, alpha: false, palette: false, bytes: 1000, depth: 8 }, k)).toEqual([]); + }); + test('orientation landscape needs the bleed layout', () => { + expect(validateConfig(base({ layout: 'device' })).join(' ')).toContain("needs shots.layout: 'bleed'"); + expect(validateConfig(base()).join(' ')).not.toContain('orientation'); + }); +}); + +describe('bleed layout', () => { + test('config checks', () => { + const bad = base({ layout: 'full' as never, orientation: 'sideways' as never, scenes: [{ id: 'a', palette: 'night', frames: [{ src: 'a.png' }], corner: 'mid' as never, captionScale: 3 }] }); + const e = validateConfig(bad).join('\n'); + expect(e).toContain('shots.layout: device | bleed'); + expect(e).toContain('shots.orientation: portrait | landscape'); + expect(e).toContain('corner tl | tr | bl | br'); + expect(e).toContain('captionScale 0.6-1.4'); + }); + test('the pages ship embedded and draw no band behind the caption', () => { + for (const p of ['template-bleed.html', 'feature-bleed.html']) { + const html = SHOTS_PAGES[p]!; + expect(html).toBeTruthy(); + expect(html).toContain('text-shadow'); + // owner rule: legibility from a text shadow and glow, never a dark band, scrim or box behind the words + const css = html.slice(html.indexOf('')); + expect(css).not.toMatch(/\.(cap|name)\b[^}]*background/); + expect(css).not.toMatch(/linear-gradient|scrim|band/); + } + }); + test('frames per size at native pixels', () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-bleed-')); + try { + const c = base(); + const put = (rel: string, w: number, h: number) => { mkdirSync(join(dir, rel, '..'), { recursive: true }); writeFileSync(join(dir, rel), png(w, h)); }; + const missing = checkFrameFolder(c, dir, ['en']).map(i => i.file); + expect(missing).toContain('en/ios69/a.png'); + expect(missing).toContain('en/mac/a.png'); + expect(missing).toContain('en/feature/f.png'); + put('en/ios69/a.png', 2868, 1320); put('en/ios65/a.png', 2778, 1284); put('en/mac/a.png', 2880, 1800); + put('en/play/a.png', 1920, 1080); put('en/feature/f.png', 2048, 1000); + expect(checkFrameFolder(c, dir, ['en'])).toEqual([]); + put('en/play/a.png', 1080, 1920); + expect(checkFrameFolder(c, dir, ['en'])[0]!.msg).toContain('aspect'); + put('en/play/a.png', 960, 540); + expect(checkFrameFolder(c, dir, ['en'])[0]!.msg).toContain('native resolution'); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + test('render serves the bleed pages and names the size in the URL', () => { + const src = readFileSync(join(import.meta.dir, '..', 'cli', 'shots', 'render.ts'), 'utf8'); + expect(src).toContain("bleedLayout ? 'template-bleed'"); + expect(src).toContain('&size=${encodeURIComponent(size)}'); + expect(SHOTS_PAGES['template-bleed.html']).toContain('/frames/${encodeURIComponent(LANG)}/${encodeURIComponent(SIZE)}/'); + }); +});