shots: app titles get the weights the templates use (variable axis, used range or static weights; font-weight warning), palette.bg as one colour or a CSS gradient string (validated; contrast guard in shots check), photo backgrounds (palette.image, shots.feature.background) with overlay and cover crop
This commit is contained in:
parent
2ba8278ab7
commit
3a11682a37
21 changed files with 713 additions and 44 deletions
|
|
@ -7,6 +7,15 @@
|
|||
- **`shots capture` / `render` find Playwright's chromium in a project's CommonJS playwright** whether it comes on the
|
||||
namespace or under `default`; when the module has no `chromium.launch`, the error names the resolved file and its
|
||||
exports (was "Cannot read properties of undefined (reading 'launch')").
|
||||
- **Heavy app-style headlines again:** `shots fonts` downloads the weights the templates use (the full `wght` axis of a
|
||||
variable Google family, else the used range, else the static weights), so a family like Plus Jakarta Sans no longer
|
||||
falls back to its thin 400 instance. `shots fonts` warns about weights a family or a local file (`{ weight }`) lacks;
|
||||
`shots check` warns per image when a title was drawn at another weight (`font-weight`).
|
||||
- **palette.bg as a string:** one colour (flat) or a CSS gradient string now renders as given instead of a white
|
||||
background; anything else fails config validation ("palette.bg must be a colour, a gradient or an array of 2-3
|
||||
colours"). `shots check` warns when the title ink has low contrast against the pixels behind it (`contrast`).
|
||||
- **Photo backgrounds:** `palette.image` (+ `overlay`) and `shots.feature.background` put a cover-cropped photo under a
|
||||
dark overlay (the Play feature graphic, optionally the screenshots); `shots check` checks the file and its size.
|
||||
|
||||
## 0.8.18 · 2026-10-20
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|||
import * as rt from './rt';
|
||||
import { PROFILE_RE, setConfigAccount } from './credentials';
|
||||
import { validateCapture, validateRecord } from '../shots/devices';
|
||||
import { featureBackgroundIssues, paletteIssues } from '../shots/palette';
|
||||
import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy';
|
||||
import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events';
|
||||
import { type IapOfferCodeDef, type PromotedConfig, validateIapOfferCodes, validatePromoted, validateWinBack, type WinBackDef } from './offers';
|
||||
|
|
@ -125,7 +126,9 @@ export interface DataSafety {
|
|||
|
||||
export interface ShotBadgeSlot { y: number; side: 'l' | 'r'; icon?: string; iconBg?: string; hot?: boolean; stars?: boolean; rot?: number }
|
||||
export interface ShotFrame { src: string; x?: number; dy?: number; scale?: number; rotate?: number }
|
||||
export interface Palette { bg: [string, string, string]; glow: string; ink: string; shadow: string }
|
||||
/** 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 }
|
||||
|
||||
/** A local font file for shots.fonts. license: 'OFL-1.1', 'proprietary' or a short note (shown by `shots fonts`;
|
||||
|
|
@ -158,7 +161,10 @@ export interface ShotsConfig {
|
|||
* 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 };
|
||||
feature?: { frame: string; palette?: string | Palette; fit?: 'contain' | 'bleed'; scale?: number;
|
||||
/** 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 } };
|
||||
/** 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 };
|
||||
|
|
@ -1209,7 +1215,11 @@ export function validateConfig(c: unknown): string[] {
|
|||
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');
|
||||
e.push(...featureBackgroundIssues(s.feature?.background));
|
||||
if (s.feature?.palette !== undefined) e.push(...paletteIssues(s.feature.palette, 'shots.feature.palette'));
|
||||
}
|
||||
for (const sc of s.scenes ?? []) if (sc.palette !== undefined) e.push(...paletteIssues(sc.palette, `shots.scenes[${sc.id}].palette`));
|
||||
for (const [k, v] of Object.entries(s.variants ?? {})) for (const [id, p] of Object.entries(v.palettes ?? {})) e.push(...paletteIssues(p, `shots.variants.${k}.palettes.${id}`));
|
||||
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
|
|
@ -21,6 +21,39 @@ import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders
|
|||
import { imageInfo } from './spec';
|
||||
import { fontsDirOf, readManifest } from './fonts';
|
||||
import { localFontIssues } from './local-fonts';
|
||||
import { photoBackground, photoIssues } from './palette';
|
||||
import { palette } from './lib';
|
||||
import { shotsStyle } from '../lib/config';
|
||||
import type { Issue } from './spec';
|
||||
|
||||
/** Photo backgrounds (palette.image, shots.feature.background): the file exists and is at least as big as every
|
||||
* output it covers (the feature graphic 1024 x 500; screenshots at the sizes this project renders). */
|
||||
export function backgroundIssues(config: ShipConfig, resolve: (p: string) => string, tablets?: boolean): { where: string; issue: Issue }[] {
|
||||
const shots = config.shots;
|
||||
if (!shots) return [];
|
||||
const style = shotsStyle(config), sizes = defaultSizes(config, tablets);
|
||||
const out: { where: string; issue: Issue }[] = [];
|
||||
const one = (file: string, where: string, targets: string[]): void => {
|
||||
const abs = resolve(file);
|
||||
let info: { w: number; h: number } | null = null;
|
||||
try { const i = imageInfo(readFileSync(abs)); info = i.format === 'unknown' ? null : i; } catch { /* missing */ }
|
||||
for (const t of targets) for (const x of photoIssues(info, file, SIZES[t]!.w, SIZES[t]!.h)) out.push({ where, issue: { level: x.level, code: 'background', msg: x.msg } });
|
||||
};
|
||||
const screens = sizes.filter(s => !SIZES[s]!.feature);
|
||||
// the biggest screenshot size decides; one message per file
|
||||
const biggest = screens.sort((a, b) => SIZES[b]!.w * SIZES[b]!.h - SIZES[a]!.w * SIZES[a]!.h).slice(0, 1);
|
||||
for (const sc of shots.scenes) {
|
||||
let p; try { p = palette(sc.palette, style); } catch (e) { out.push({ where: `shots.scenes[${sc.id}].palette`, issue: { level: 'error', code: 'background', msg: (e as Error).message } }); continue; }
|
||||
const ph = photoBackground(p);
|
||||
if (ph) one(ph.image, `shots.scenes[${sc.id}].palette.image`, biggest);
|
||||
}
|
||||
if (sizes.includes('feature')) {
|
||||
let p; try { p = palette(shots.feature?.palette ?? shots.scenes[0]?.palette, style); } catch { p = null; }
|
||||
const ph = p && photoBackground(p, shots.feature?.background);
|
||||
if (ph) one(ph.image, shots.feature?.background ? 'shots.feature.background' : 'shots.feature palette.image', ['feature']);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Frame files the scenes (and the feature graphic) use, per language and device class. */
|
||||
export function framesNeeded(config: ShipConfig, langs: string[], tablets?: boolean): { lang: string; device: 'phone' | 'tablet'; name: string }[] {
|
||||
|
|
@ -66,9 +99,10 @@ export function rendersCheck(config: ShipConfig, dir: string): Record<'apple' |
|
|||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, resolve, root } = ctx.loaded;
|
||||
const scan = scanProject(ctx);
|
||||
const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue }))];
|
||||
const cap = config.shots?.capture;
|
||||
const tablets = resolvedTablets(config, resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
|
||||
const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue })),
|
||||
...backgroundIssues(config, resolve, tablets)];
|
||||
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs, tablets) : [];
|
||||
if (ctx.args.has('json')) {
|
||||
printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues });
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const lang = q.get('lang'), V = q.get('v') ?? '';
|
|||
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(lang)}&feature=1&v=${encodeURIComponent(V)}`)).json();
|
||||
const P = job.palette, R = document.documentElement.style, dev = job.phone;
|
||||
const rgba = (hex, a) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return hex; const n = parseInt(m[1], 16); return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`; };
|
||||
R.setProperty('--bg', `linear-gradient(135deg, ${P.bg[0]} 0%, ${P.bg[1]} 55%, ${P.bg[2]} 100%)`);
|
||||
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(135deg, ${P.bg.join(', ')})` : P.bg)); // palette.ts: stops, colour, gradient string or photo + overlay
|
||||
R.setProperty('--ink', P.ink); R.setProperty('--shadow', P.shadow); R.setProperty('--accentSoft', rgba(P.glow, .18));
|
||||
R.setProperty('--tf', (job.fonts.titleStack ?? [job.fonts.title]).map(f => `'${f}'`).join(', ') + ', sans-serif');
|
||||
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const q = new URLSearchParams(location.search);
|
|||
const lang = q.get('lang'), V = q.get('v') ?? '';
|
||||
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(lang)}&feature=1&v=${encodeURIComponent(V)}`)).json();
|
||||
const P = job.palette, R = document.documentElement.style, dev = job.phone;
|
||||
R.setProperty('--bg', `linear-gradient(160deg, ${P.bg[0]} 0%, ${P.bg[1]} 50%, ${P.bg[2]} 100%)`);
|
||||
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(160deg, ${P.bg.join(', ')})` : P.bg)); // palette.ts: stops, colour, gradient string or photo + overlay
|
||||
R.setProperty('--glow', P.glow); R.setProperty('--ink', P.ink); R.setProperty('--shadow', P.shadow);
|
||||
R.setProperty('--tf', (job.fonts.titleStack ?? [job.fonts.title]).map(f => `'${f}'`).join(', ') + ', sans-serif');
|
||||
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
|
||||
|
|
|
|||
|
|
@ -19,21 +19,19 @@ import { shotsStyle } from '../lib/config';
|
|||
import type { CopyFile } from './lib';
|
||||
import { localByFamily, resolveShotFonts } from './local-fonts';
|
||||
import { fontCodepoints } from './cmap';
|
||||
import { type FaceJob, type FontManifest, fontsCss, planFonts, roleTexts, type RoleTexts } from './glyphs';
|
||||
import { SHOTS_PAGES } from '../lib/embedded';
|
||||
import { fetchFaces, localWeightGap, serves, styleWeights } from './weights';
|
||||
import { type FaceJob, type FaceResult, type FontManifest, fontsCss, planFonts, roleTexts, type RoleTexts } from './glyphs';
|
||||
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||
const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') + '·';
|
||||
|
||||
export async function fetchSubset(family: string, chars: string, axis: string | null): Promise<ArrayBuffer> {
|
||||
const fam = encodeURIComponent(family) + (axis ? ':' + axis : '');
|
||||
let res = await fetch(`https://fonts.googleapis.com/css2?family=${fam}&text=${encodeURIComponent(chars)}&display=block`, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok && axis) return fetchSubset(family, chars, null);
|
||||
if (!res.ok) throw new Error(`${family}: ${res.status} ${(await res.text()).slice(0, 200)}`);
|
||||
const src = /url\((https:[^)]+)\)/.exec(await res.text())?.[1];
|
||||
if (!src) throw new Error(`${family}: no font url`);
|
||||
res = await fetch(src, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok) throw new Error(`${family}: ${res.status} for ${src}`);
|
||||
return res.arrayBuffer();
|
||||
return (await fetchFaces(family, chars, { axis }))[0]!.bin;
|
||||
}
|
||||
|
||||
/** Weights the style's templates (screenshot + feature graphic) ask for, per role. */
|
||||
export function weightsFor(style: 'game' | 'app'): { title: number[]; body: number[] } {
|
||||
return styleWeights(style === 'app' ? [SHOTS_PAGES['template-app.html']!, SHOTS_PAGES['feature-app.html']!] : [SHOTS_PAGES['template.html']!, SHOTS_PAGES['feature.html']!]);
|
||||
}
|
||||
|
||||
/** Per-language role texts for every language of the copy and the config. */
|
||||
|
|
@ -57,16 +55,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
mkdirSync(dir, { recursive: true });
|
||||
const style = shotsStyle(config);
|
||||
const sf = resolveShotFonts(shots, style, resolve, root);
|
||||
const { titleVariable } = sf;
|
||||
const W = weightsFor(style);
|
||||
const title = sf.title.family, body = sf.body.family;
|
||||
const local = localByFamily([sf.title, sf.body, ...sf.fallback]);
|
||||
const texts = langTexts(copy, config.languages.map(l => l.id), config.app.name);
|
||||
|
||||
const sizes: string[] = [];
|
||||
const manifest = await planFonts({
|
||||
langs: texts, title, body, titleAxis: titleVariable ? 'wght@100..900' : null, bodyAxis: 'wght@100..900',
|
||||
langs: texts, title, body, titleAxis: null, bodyAxis: null, titleWeights: W.title, bodyWeights: W.body,
|
||||
fallbackTitleAxis: style === 'app' ? 'wght@100..900' : 'wght@800', chosen: sf.fallback.map(f => f.family), base: ASCII, local,
|
||||
}, async (job: FaceJob) => {
|
||||
}, async (job: FaceJob): Promise<FaceResult> => {
|
||||
if (job.local) { // read in place: the whole file's cmap
|
||||
const bin = new Uint8Array(readFileSync(join(root, job.local.path)));
|
||||
const cps = fontCodepoints(bin);
|
||||
|
|
@ -74,21 +72,39 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (!sizes.includes(line)) sizes.push(line);
|
||||
return cps;
|
||||
}
|
||||
let bin: Uint8Array;
|
||||
try { bin = new Uint8Array(await fetchSubset(job.family, job.chars, job.axis)); } catch (e) {
|
||||
let got: { weight: string; bin: ArrayBuffer }[];
|
||||
try { got = await fetchFaces(job.family, job.chars, job.weights ? { weights: job.weights } : { axis: job.axis }); } catch (e) {
|
||||
if (job.kind !== 'fallback') throw e;
|
||||
log('shots', `${job.family} has none of ${[...job.chars].join(' ')} (${(e as Error).message.split(' for ')[0]}), trying the next fallback`);
|
||||
return new Set<number>();
|
||||
}
|
||||
writeFileSync(join(dir, job.file), bin);
|
||||
const bin = new Uint8Array(got[0]!.bin);
|
||||
writeFileSync(join(dir, job.file), bin); // the cmap / coverage file (every weight has the same subset)
|
||||
const cps = fontCodepoints(bin);
|
||||
sizes.push(`${job.file.padEnd(28)} ${job.family.padEnd(22)} ${(bin.byteLength / 1024).toFixed(1)} KB, ${[...job.chars].length} chars asked, ${cps.size} in the cmap`);
|
||||
const weights = got.map(g => g.weight).join(', ');
|
||||
sizes.push(`${job.file.padEnd(28)} ${job.family.padEnd(22)} ${(got.reduce((t, g) => t + g.bin.byteLength, 0) / 1024).toFixed(1)} KB, weight ${weights}, ${[...job.chars].length} chars asked, ${cps.size} in the cmap`);
|
||||
if (job.weights) {
|
||||
const files = got.length === 1 ? [{ file: job.file, weight: got[0]!.weight }] : got.map(g => {
|
||||
const file = job.file.replace(/\.woff2$/, `-${g.weight.replace(/ /g, '-')}.woff2`);
|
||||
writeFileSync(join(dir, file), new Uint8Array(g.bin));
|
||||
return { file, weight: g.weight };
|
||||
});
|
||||
const miss = job.weights.filter(w => !files.some(f => serves(f.weight, w)));
|
||||
if (miss.length) warn('shots', `${job.family} (${job.role ?? job.kind}) has no weight ${miss.join(', ')} (got ${weights}): the templates ask for it, so those lines are drawn at the nearest weight. Pick a family with these weights for a heavier headline`);
|
||||
return { cps, files };
|
||||
}
|
||||
return cps;
|
||||
});
|
||||
writeFileSync(join(dir, 'fonts.css'), fontsCss(manifest));
|
||||
writeFileSync(join(dir, 'fonts.json'), JSON.stringify(manifest, null, 1) + '\n');
|
||||
if (ctx.args.has('json')) return printJson(manifest);
|
||||
for (const s of sizes) log('shots', s);
|
||||
for (const [role, ref] of [['title', sf.title], ['body', sf.body]] as const) {
|
||||
if (!ref.local) continue;
|
||||
const gap = localWeightGap(ref.local.weight, W[role]);
|
||||
if (gap.length) warn('shots', `local font ${ref.local.path} is declared as weight ${ref.local.weight}, but the templates ask the ${role} for ${gap.join(', ')}: those lines are drawn at ${ref.local.weight} (never faked bolder). Add a file with that weight or accept it`);
|
||||
else if (ref.local.weight === undefined && role === 'title') log('shots', `local font ${ref.local.path} has no weight: declared for 100-900, so a static file draws its own weight for the ${role} weights ${W[role].join(', ')}. Give { weight } for a static file`);
|
||||
}
|
||||
for (const f of Object.values(local)) if (!f.license) warn('shots', `local font ${f.path} has no license note: add license ('OFL-1.1', 'proprietary', ...) to its shots.fonts entry`);
|
||||
let auto = 0;
|
||||
for (const [lang, l] of Object.entries(manifest.langs)) for (const f of l.fallbacks) {
|
||||
|
|
|
|||
|
|
@ -66,12 +66,19 @@ export const FALLBACK_ORDER = ['Noto Sans Display', 'Noto Sans', 'Noto Sans KR',
|
|||
export type FaceKind = 'title' | 'body' | 'script' | 'fallback';
|
||||
/** One downloaded face: `face` is the @font-face family name the templates use. axis: css2 axis spec ("wght@800",
|
||||
* "wght@100..900") or null for the font's default instance. */
|
||||
export interface FaceJob { face: string; family: string; file: string; kind: FaceKind; role?: Role; axis: string | null; chars: string; local?: LocalFontInfo }
|
||||
export interface FaceJob { face: string; family: string; file: string; kind: FaceKind; role?: Role; axis: string | null; chars: string; local?: LocalFontInfo;
|
||||
/** Weights the templates use for this face's role (title / body): the getter fetches them (weights.ts fetchFaces). */
|
||||
weights?: number[] }
|
||||
/** What a getter returns: the covered code points, and (several weights downloaded) the files with their weight. */
|
||||
export type FaceResult = Set<number> | { cps: Set<number>; files: FaceFile[] };
|
||||
export interface FaceFile { file: string; weight: string }
|
||||
export interface LangFonts { title: string[]; body: string[]; fallbacks: { role: Role; face: string; family: string; chars: string }[] }
|
||||
export interface FontManifest {
|
||||
v: 1;
|
||||
/** local: a local font file (shots.fonts entry with a path), read in place, never copied; file = its served path. */
|
||||
faces: Record<string, { family: string; file: string; kind: FaceKind; role?: Role; chosen: boolean; local?: LocalFontInfo }>;
|
||||
faces: Record<string, { family: string; file: string; kind: FaceKind; role?: Role; chosen: boolean; local?: LocalFontInfo;
|
||||
/** Downloaded files per weight ("700", "200 800"); none = one file for every weight (older manifests). */
|
||||
files?: FaceFile[] }>;
|
||||
langs: Record<string, LangFonts>;
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +87,8 @@ export interface PlanInput {
|
|||
title: string; body: string;
|
||||
/** css2 axis for the title / body family (null = default instance). */
|
||||
titleAxis: string | null; bodyAxis: string | null;
|
||||
/** Weights the templates use per role (weights.ts styleWeights); set = fetch these instead of titleAxis / bodyAxis. */
|
||||
titleWeights?: number[]; bodyWeights?: number[];
|
||||
/** Weight axis for automatic title fallbacks (game titles are heavy display faces: "wght@800"). */
|
||||
fallbackTitleAxis: string | null;
|
||||
/** shots.fonts.fallback: families the owner accepts as fallbacks, in order. Families that are not one of the automatic
|
||||
|
|
@ -102,7 +111,14 @@ export class GlyphError extends Error {
|
|||
/** Faces for every language's copy: title + body, one Noto face per non-Latin script (whole lines, as before), and an
|
||||
* automatic Noto fallback for each character the stack still lacks. `get` downloads a face (only the listed chars)
|
||||
* and returns the code points its cmap covers. Throws GlyphError when no fallback covers a character. */
|
||||
export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise<Set<number>>): Promise<FontManifest> {
|
||||
export async function planFonts(input: PlanInput, get0: (job: FaceJob) => Promise<FaceResult>): Promise<FontManifest> {
|
||||
const files = new Map<string, FaceFile[]>();
|
||||
const get = async (job: FaceJob): Promise<Set<number>> => {
|
||||
const r = await get0(job);
|
||||
if (r instanceof Set) return r;
|
||||
files.set(job.face, r.files);
|
||||
return r.cps;
|
||||
};
|
||||
const chosen = new Set((input.chosen ?? []).map(s => s.toLowerCase()));
|
||||
const local = input.local ?? {};
|
||||
const extra = [...new Set(input.chosen ?? [])].filter(f => !FALLBACK_ORDER.some(n => n.toLowerCase() === f.toLowerCase()));
|
||||
|
|
@ -130,13 +146,15 @@ export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise
|
|||
const add = async (job: FaceJob, isChosen: boolean): Promise<void> => {
|
||||
cps.set(job.face, await get(job));
|
||||
if (job.kind === 'fallback' && ![...job.chars].some(ch => cps.get(job.face)!.has(ch.codePointAt(0)!))) return; // covers none: not a face
|
||||
faces[job.face] = { family: job.family, file: job.file, kind: job.kind, ...(job.role ? { role: job.role } : {}), chosen: isChosen, ...(job.local ? { local: job.local } : {}) };
|
||||
faces[job.face] = { family: job.family, file: job.file, kind: job.kind, ...(job.role ? { role: job.role } : {}), chosen: isChosen, ...(job.local ? { local: job.local } : {}), ...(files.has(job.face) ? { files: files.get(job.face) } : {}) };
|
||||
};
|
||||
await add({ face: input.title, family: input.title, file: fileOf(input.title, 'title.woff2'), kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted(roleChars.title), ...loc(input.title) }, true);
|
||||
if (input.body !== input.title) await add({ face: input.body, family: input.body, file: fileOf(input.body, 'body.woff2'), kind: 'body', role: 'body', axis: input.bodyAxis, chars: sorted(roleChars.body), ...loc(input.body) }, true);
|
||||
await add({ face: input.title, family: input.title, file: fileOf(input.title, 'title.woff2'), kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted(roleChars.title), ...loc(input.title), ...(input.titleWeights ? { weights: input.titleWeights } : {}) }, true);
|
||||
if (input.body !== input.title) await add({ face: input.body, family: input.body, file: fileOf(input.body, 'body.woff2'), kind: 'body', role: 'body', axis: input.bodyAxis, chars: sorted(roleChars.body), ...loc(input.body), ...(input.bodyWeights ? { weights: input.bodyWeights } : {}) }, true);
|
||||
else { // same family: one download (title axis) with both roles' characters
|
||||
const t = faces[input.title]!;
|
||||
cps.set(input.title, await get({ face: input.title, family: input.title, file: t.file, kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted([...roleChars.title, ...roleChars.body]), ...loc(input.title) }));
|
||||
cps.set(input.title, await get({ face: input.title, family: input.title, file: t.file, kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted([...roleChars.title, ...roleChars.body]), ...loc(input.title),
|
||||
...(input.titleWeights ? { weights: [...new Set([...input.titleWeights, ...(input.bodyWeights ?? [])])].sort((a, b) => a - b) } : {}) }));
|
||||
if (files.has(input.title)) t.files = files.get(input.title);
|
||||
}
|
||||
for (const [s, v] of scripts) await add({ face: `Store ${s}`, family: v.family, file: `${s}.woff2`, kind: 'script', axis: 'wght@100..900', chars: sorted(v.chars) }, true);
|
||||
|
||||
|
|
@ -202,6 +220,10 @@ export function fontsCss(m: FontManifest): string {
|
|||
css += `@font-face { font-family: '${face}'; font-weight: ${l.weight ?? '100 900'}; font-style: ${l.style ?? 'normal'}; font-synthesis: none; src: url(${f.file}?v=${l.hash}) format('${l.format}'); }\n`;
|
||||
continue;
|
||||
}
|
||||
if (f.files?.length) { // one file per downloaded weight: a weight the family lacks is drawn at the nearest one (never faked)
|
||||
for (const x of f.files) css += `@font-face { font-family: '${face}'; font-weight: ${x.weight}; font-synthesis: none; src: url(${x.file}) format('woff2'); }\n`;
|
||||
continue;
|
||||
}
|
||||
css += `@font-face { font-family: '${face}'; font-weight: 100 900; font-synthesis: none; src: url(${f.file}) format('woff2'); }\n`;
|
||||
}
|
||||
return css;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
|
||||
import { specFamily } from './local-fonts';
|
||||
import { bgIssue } from './palette';
|
||||
import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config';
|
||||
|
||||
/** Store image sizes. ios69 = 6.9" (APP_IPHONE_67), ios65 = 6.5" (APP_IPHONE_65), iphone-medium = 6.3" iPhone with
|
||||
|
|
@ -94,7 +95,11 @@ export const APP_PALETTES: Record<string, Palette> = {
|
|||
export const palette = (p: string | Palette | undefined, style: 'game' | 'app' = 'game'): Palette => {
|
||||
const own = style === 'app' ? APP_PALETTES : PALETTES;
|
||||
if (!p) return style === 'app' ? APP_PALETTES.mist! : PALETTES.fire!;
|
||||
if (typeof p !== 'string') return p;
|
||||
if (typeof p !== 'string') {
|
||||
const bad = bgIssue(p.bg);
|
||||
if (bad) throw new Error(`${bad} (got ${JSON.stringify(p.bg)})`);
|
||||
return p;
|
||||
}
|
||||
const hit = own[p] ?? PALETTES[p] ?? APP_PALETTES[p];
|
||||
if (!hit) throw new Error(`unknown palette "${p}" (${Object.keys(own).join(', ')}) or pass { bg, glow, ink, shadow }`);
|
||||
return hit;
|
||||
|
|
|
|||
131
cli/shots/palette.ts
Normal file
131
cli/shots/palette.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Palette backgrounds, photo backgrounds and the contrast guard. Pure (no fs, no browser): config validation, render
|
||||
// (job.json) and `shots check` share it.
|
||||
// bg: ['#a', '#b', '#c'] | ['#a', '#b'] gradient stops (each template keeps its own angle)
|
||||
// '#15171C' | 'rgb(...)' | 'navy' one colour, flat
|
||||
// 'linear-gradient(...)' any CSS gradient, passed through as is
|
||||
// image: 'store/art/x.png', overlay: 0.45 photo under a dark overlay, cropped to cover (palette or shots.feature.background)
|
||||
import type { Palette } from '../lib/config';
|
||||
|
||||
const COLOUR_RE = /^(#[0-9a-f]{3,4}|#[0-9a-f]{6}|#[0-9a-f]{8}|(rgb|hsl)a?\([^()]*\)|[a-z]{3,30})$/i;
|
||||
const GRADIENT_RE = /^(repeating-)?(linear|radial|conic)-gradient\(.*\)$/is;
|
||||
export const isColour = (s: unknown): s is string => typeof s === 'string' && COLOUR_RE.test(s.trim()) && !/^(none|inherit|initial|unset)$/i.test(s.trim());
|
||||
export const isGradient = (s: unknown): s is string => typeof s === 'string' && GRADIENT_RE.test(s.trim());
|
||||
|
||||
export const BG_ERROR = 'palette.bg must be a colour, a gradient or an array of 2-3 colours';
|
||||
export const DEFAULT_OVERLAY = 0.45;
|
||||
|
||||
/** Problem with a palette's bg (null = fine). */
|
||||
export function bgIssue(bg: unknown): string | null {
|
||||
if (isColour(bg) || isGradient(bg)) return null;
|
||||
if (Array.isArray(bg) && bg.length >= 2 && bg.length <= 3 && bg.every(isColour)) return null;
|
||||
return BG_ERROR;
|
||||
}
|
||||
|
||||
const overlayOk = (o: unknown): boolean => o === undefined || (typeof o === 'number' && o >= 0 && o <= 0.9);
|
||||
const imageOk = (i: unknown): boolean => typeof i === 'string' && /\.(png|jpe?g|webp)$/i.test(i.trim());
|
||||
|
||||
/** Config validation of one palette (a named palette string is checked when it is resolved). */
|
||||
export function paletteIssues(p: unknown, key: string): string[] {
|
||||
if (typeof p === 'string') return [];
|
||||
if (!p || typeof p !== 'object') return [`${key}: a palette name or { bg, glow, ink, shadow }`];
|
||||
const o = p as Partial<Palette>, e: string[] = [];
|
||||
const bi = bgIssue(o.bg);
|
||||
if (bi) e.push(`${key}.bg: ${bi} (got ${JSON.stringify(o.bg)})`);
|
||||
for (const k of ['glow', 'ink', 'shadow'] as const) if (!isColour(o[k])) e.push(`${key}.${k}: a colour`);
|
||||
if (o.image !== undefined && !imageOk(o.image)) e.push(`${key}.image: a PNG, JPEG or WebP path (relative to the config)`);
|
||||
if (!overlayOk(o.overlay)) e.push(`${key}.overlay: a number from 0 to 0.9 (dark overlay over the image)`);
|
||||
return e;
|
||||
}
|
||||
|
||||
export function featureBackgroundIssues(b: unknown): string[] {
|
||||
if (b === undefined) return [];
|
||||
const o = typeof b === 'string' ? { image: b } : b as { image?: unknown; overlay?: unknown } | null;
|
||||
if (!o || typeof o !== 'object' || !imageOk(o.image)) return ['shots.feature.background: a PNG, JPEG or WebP path or { image, overlay } (relative to the config)'];
|
||||
return overlayOk(o.overlay) ? [] : ['shots.feature.background.overlay: a number from 0 to 0.9'];
|
||||
}
|
||||
|
||||
/** The photo background in effect: shots.feature.background (feature graphic only), else the palette's image. */
|
||||
export function photoBackground(p: Palette, featureBg?: string | { image: string; overlay?: number }): { image: string; overlay: number } | null {
|
||||
const f = typeof featureBg === 'string' ? { image: featureBg } : featureBg;
|
||||
const image = f?.image ?? p.image;
|
||||
if (!image) return null;
|
||||
return { image, overlay: f?.overlay ?? p.overlay ?? DEFAULT_OVERLAY };
|
||||
}
|
||||
|
||||
/** CSS background for a palette bg at a template's angle (stops at 0 %, mid %, 100 %). */
|
||||
export function bgCss(bg: Palette['bg'], angle = 180, mid = 55): string {
|
||||
if (typeof bg === 'string') return bg.trim();
|
||||
if (bg.length === 2) return bg[0] === bg[1] ? bg[0] : `linear-gradient(${angle}deg, ${bg[0]} 0%, ${bg[1]} 100%)`;
|
||||
if (bg[0] === bg[1] && bg[1] === bg[2]) return bg[0];
|
||||
return `linear-gradient(${angle}deg, ${bg[0]} 0%, ${bg[1]} ${mid}%, ${bg[2]} 100%)`;
|
||||
}
|
||||
|
||||
/** A photo under a flat dark overlay, cropped to cover (CSS cover = coverCrop below). */
|
||||
export function photoCss(url: string, overlay: number): string {
|
||||
return `linear-gradient(rgba(0,0,0,${overlay}), rgba(0,0,0,${overlay})), url('${url}') center / cover no-repeat`;
|
||||
}
|
||||
|
||||
/** One colour that stands for the background (the middle stop, the colour, the first colour of a gradient), for
|
||||
* light / dark decisions in the templates. A photo counts as dark from an overlay of 0.35 up. */
|
||||
export function bgTone(bg: Palette['bg']): string {
|
||||
if (Array.isArray(bg)) return bg[Math.floor(bg.length / 2)]!;
|
||||
const m = /#[0-9a-f]{3,8}\b|(rgb|hsl)a?\([^()]*\)/i.exec(bg);
|
||||
return m ? m[0] : bg;
|
||||
}
|
||||
|
||||
/** Source rectangle a cover crop takes from a srcW x srcH image for an outW x outH box (centred), and the scale
|
||||
* (> 1 = the image is upscaled: too small for the output). */
|
||||
export function coverCrop(srcW: number, srcH: number, outW: number, outH: number): { x: number; y: number; w: number; h: number; scale: number } {
|
||||
const scale = Math.max(outW / srcW, outH / srcH);
|
||||
const w = outW / scale, h = outH / scale;
|
||||
return { x: (srcW - w) / 2, y: (srcH - h) / 2, w, h, scale };
|
||||
}
|
||||
|
||||
/** Problems of a photo background for one output size: missing file, smaller than the output (upscaled = blurry). */
|
||||
export function photoIssues(img: { w: number; h: number } | null, file: string, outW: number, outH: number): { level: 'error' | 'warn'; msg: string }[] {
|
||||
if (!img) return [{ level: 'error', msg: `background image ${file} not found or not a PNG / JPEG / WebP` }];
|
||||
const c = coverCrop(img.w, img.h, outW, outH);
|
||||
return c.scale > 1.001 ? [{ level: 'warn', msg: `background image ${file} is ${img.w}x${img.h}, smaller than the ${outW}x${outH} output (upscaled ${c.scale.toFixed(2)}x, blurry): use one at least ${outW}x${outH}` }] : [];
|
||||
}
|
||||
|
||||
// ---- contrast -------------------------------------------------------------------------------------------------
|
||||
|
||||
export type RGB = [number, number, number];
|
||||
|
||||
/** #rgb / #rrggbb / rgb(a)() to RGB (alpha ignored); null for anything else. */
|
||||
export function parseColour(s: string): RGB | null {
|
||||
const t = s.trim();
|
||||
let m = /^#([0-9a-f]{3})$/i.exec(t);
|
||||
if (m) return [...m[1]!].map(c => parseInt(c + c, 16)) as RGB;
|
||||
m = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i.exec(t);
|
||||
if (m) { const n = parseInt(m[1]!, 16); return [n >> 16, (n >> 8) & 255, n & 255]; }
|
||||
m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(t);
|
||||
if (m) return [+m[1]!, +m[2]!, +m[3]!];
|
||||
return null;
|
||||
}
|
||||
|
||||
const lin = (c: number): number => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
|
||||
export const luminance = ([r, g, b]: RGB): number => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
/** WCAG contrast ratio, 1..21. */
|
||||
export function contrastRatio(a: RGB, b: RGB): number {
|
||||
const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) as [number, number];
|
||||
return (x + 0.05) / (y + 0.05);
|
||||
}
|
||||
|
||||
/** One title element's contrast as `shots render` records it: ink colour, the background samples behind the text
|
||||
* (text hidden), and the worst ratio. */
|
||||
export interface ContrastSample { el: string; ink: string; worst: RGB; ratio: number }
|
||||
|
||||
/** Worst contrast of an ink colour over background pixel samples. */
|
||||
export function worstContrast(ink: RGB, samples: RGB[]): { worst: RGB; ratio: number } {
|
||||
let best = { worst: samples[0] ?? ink, ratio: 21 };
|
||||
for (const s of samples) { const r = contrastRatio(ink, s); if (r < best.ratio) best = { worst: s, ratio: r }; }
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Large headline text: WCAG AA large text asks 3:1; under 2:1 it is close to invisible (white on white). */
|
||||
export const CONTRAST_MIN = 3;
|
||||
export function contrastIssues(samples: ContrastSample[] | undefined): { level: 'warn'; code: 'contrast'; msg: string }[] {
|
||||
return (samples ?? []).filter(s => s.ratio < CONTRAST_MIN).map(s => ({ level: 'warn' as const, code: 'contrast' as const,
|
||||
msg: `${s.el} ink ${s.ink} has low contrast against the background behind it (${s.ratio.toFixed(2)}:1, rgb(${s.worst.join(',')}); want >= ${CONTRAST_MIN}:1)${s.ratio < 1.5 ? ': the text is close to invisible' : ''}. Check palette.bg / ink (a string bg needs a colour or a CSS gradient) or raise the photo overlay` }));
|
||||
}
|
||||
|
|
@ -26,6 +26,9 @@ import { closeBrowser, launch } from './browser';
|
|||
import { defaultConcurrency, pool, progressLine } from './capture-web';
|
||||
import { shotsStyle } from '../lib/config';
|
||||
import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
|
||||
import { bgCss, bgTone, type ContrastSample, contrastIssues, parseColour, photoBackground, photoCss, type RGB, worstContrast } from './palette';
|
||||
import { type WeightSample, weightIssues } from './weights';
|
||||
import type { Palette } from '../lib/config';
|
||||
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
|
||||
import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
|
||||
import * as rt from '../lib/rt';
|
||||
|
|
@ -123,6 +126,19 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
for (const lang of langs) for (const f of manifest.langs[lang]?.fallbacks ?? [])
|
||||
(manifest.faces[f.face]?.chosen ? log : warn)('shots', `${lang} ${f.role}: ${[...f.chars].join(' ')} from ${f.family}${manifest.faces[f.face]?.chosen ? '' : ' (automatic fallback: shots check will fail)'}`);
|
||||
}
|
||||
// palette backgrounds as CSS (colour, gradient string or stops) and photo backgrounds, served at /bgimg/<n>
|
||||
const photos: string[] = [];
|
||||
const look = (pl: Palette, angle: number, mid: number, featureBg?: NonNullable<typeof shots.feature>['background']): Palette & { bgCss: string; bgTone: string; photo?: { overlay: number } } => {
|
||||
const ph = photoBackground(pl, featureBg);
|
||||
let css = bgCss(pl.bg, angle, mid);
|
||||
if (ph) {
|
||||
const abs = resolve(ph.image);
|
||||
if (!existsSync(abs)) throw new Error(`shots render: background image ${ph.image} not found (${abs})`);
|
||||
if (!photos.includes(abs)) photos.push(abs);
|
||||
css = photoCss(`/bgimg/${photos.indexOf(abs)}`, ph.overlay) + ', ' + css;
|
||||
}
|
||||
return { ...pl, bgCss: css, bgTone: ph && ph.overlay >= 0.35 ? '#000000' : bgTone(pl.bg), ...(ph ? { photo: { overlay: ph.overlay } } : {}) };
|
||||
};
|
||||
const server = await rt.serve({
|
||||
port: a.num('port', 8792), hostname: '127.0.0.1',
|
||||
async fetch(req) {
|
||||
|
|
@ -138,14 +154,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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,
|
||||
device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale }), palette: palette(shots.feature?.palette ?? scenes[0]!.palette, style) });
|
||||
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') ?? '';
|
||||
const scene = scenes.find(s => s.id === sid)!;
|
||||
const number = scenes.filter(s => !s.hero).indexOf(scene) + 1;
|
||||
return Response.json({ ...base, scene, number, palette: palette(scene.palette, style), copy: copy[lang]?.[sid] ?? {} });
|
||||
return Response.json({ ...base, scene, number, palette: look(palette(scene.palette, style), 180, style === 'app' ? 55 : 48), copy: copy[lang]?.[sid] ?? {} });
|
||||
}
|
||||
let file = '';
|
||||
if (p === '/logo' && shots.logo) file = resolve(shots.logo);
|
||||
else if (p.startsWith('/bgimg/')) file = photos[Number(p.slice(7))] ?? '';
|
||||
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));
|
||||
|
|
@ -195,12 +213,20 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (!out.endsWith('sheet.png')) {
|
||||
const measured = await measureFonts(page, Object.keys(faces));
|
||||
const usage = fontUsage(measured, faces);
|
||||
report[relative(outDir, out)] = { overflow, devices, ...(bleed ? { bleed } : {}), ...(boxes.length ? { boxes } : {}), missing: errs.filter(e => e.startsWith('404')), fonts: usage };
|
||||
const weights = await titleWeights(page, sf.title);
|
||||
report[relative(outDir, out)] = { overflow, devices, ...(bleed ? { bleed } : {}), ...(boxes.length ? { boxes } : {}), missing: errs.filter(e => e.startsWith('404')), fonts: usage, ...(weights.length ? { weights } : {}) };
|
||||
for (const i of weightIssues(weights)) warn('shots', `${relative(root, out)}: ${i.msg}`);
|
||||
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`);
|
||||
}
|
||||
if (overflow.length) warn('shots', `${relative(root, out)}: ${overflow.join('; ')}`);
|
||||
await page.screenshot({ path: tmp, clip: { x: 0, y: 0, width: w, height: h } });
|
||||
if (style === 'app' && !out.endsWith('sheet.png')) { // contrast guard: title ink vs. the pixels behind it (text hidden)
|
||||
const contrast = await titleContrast(page, w, h);
|
||||
const r = report[relative(outDir, out)];
|
||||
if (r && contrast.length) r.contrast = contrast;
|
||||
for (const i of contrastIssues(contrast)) warn('shots', `${relative(root, out)}: ${i.msg}`);
|
||||
}
|
||||
const ff = rt.spawn(['ffmpeg', '-v', 'error', '-y', '-i', tmp, '-pix_fmt', 'rgb24', out], { stderr: 'pipe' });
|
||||
const code = await ff.exited;
|
||||
rmSync(tmp, { force: true });
|
||||
|
|
@ -268,3 +294,52 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (flagged) warn('shots', `${flagged} images with text overflow flags: storebound shots check / shots gallery`);
|
||||
done('shots', `render done${warnings ? `, ${warnings} with warnings (missing frames or fonts?)` : ''}`);
|
||||
}
|
||||
|
||||
/** Title elements' asked weight vs. the weights the loaded faces of the title family serve (report.json `weights`). */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function titleWeights(page: any, family: string): Promise<WeightSample[]> {
|
||||
return (await page.evaluate(`(() => {
|
||||
const fam = ${JSON.stringify(family)};
|
||||
const served = [...document.fonts].filter(f => f.family.replace(/["']/g, '') === fam && f.status === 'loaded').map(f => String(f.weight));
|
||||
const seen = new Set(), out = [];
|
||||
for (const e of document.querySelectorAll('h1 span, .name')) {
|
||||
const want = Number(getComputedStyle(e).fontWeight), el = e.matches('.name') ? 'app name' : 'title';
|
||||
if (!e.textContent.trim() || seen.has(el + want)) continue;
|
||||
seen.add(el + want);
|
||||
out.push({ el, family: fam, want, served: [...new Set(served)] });
|
||||
}
|
||||
return out;
|
||||
})()`)) as WeightSample[];
|
||||
}
|
||||
|
||||
/** Title ink vs. the background behind it: hides the title text, screenshots each title box, averages it into a
|
||||
* small grid with ffmpeg and keeps the worst contrast (report.json `contrast`). Run after the real screenshot. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function titleContrast(page: any, W: number, H: number): Promise<ContrastSample[]> {
|
||||
const els = (await page.evaluate(`(() => {
|
||||
const out = [];
|
||||
for (const e of document.querySelectorAll('h1 span, .name')) {
|
||||
if (!e.textContent.trim()) continue;
|
||||
const r = e.getBoundingClientRect();
|
||||
out.push({ el: e.matches('.name') ? 'app name' : 'title', ink: getComputedStyle(e).color, x: r.x, y: r.y, w: r.width, h: r.height });
|
||||
}
|
||||
for (const e of document.querySelectorAll('h1, .name')) e.style.visibility = 'hidden';
|
||||
return out;
|
||||
})()`)) as { el: string; ink: string; x: number; y: number; w: number; h: number }[];
|
||||
const out: ContrastSample[] = [];
|
||||
for (const e of els) {
|
||||
const ink = parseColour(e.ink);
|
||||
const x = Math.max(0, Math.floor(e.x)), y = Math.max(0, Math.floor(e.y));
|
||||
const cw = Math.min(W - x, Math.ceil(e.w)), ch = Math.min(H - y, Math.ceil(e.h));
|
||||
if (!ink || cw < 2 || ch < 2) continue;
|
||||
const png = await page.screenshot({ clip: { x, y, width: cw, height: ch } }) as Uint8Array;
|
||||
const r = rt.spawnSync(['ffmpeg', '-v', 'error', '-i', 'pipe:0', '-vf', 'scale=8:3:flags=area', '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1'], { stdin: png, timeout: 15000 });
|
||||
const raw = r.stdout;
|
||||
if (r.exitCode !== 0 || raw.length < 3) continue;
|
||||
const samples: RGB[] = [];
|
||||
for (let i = 0; i + 2 < raw.length; i += 3) samples.push([raw[i]!, raw[i + 1]!, raw[i + 2]!]);
|
||||
const wc = worstContrast(ink, samples);
|
||||
out.push({ el: e.el, ink: e.ink, worst: wc.worst, ratio: Math.round(wc.ratio * 100) / 100 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { join } from 'node:path';
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import { boxOutside, type DeviceBox, marginViolations, PLAY_TABLET_SIZES, SIZES, type TextBox } from './lib';
|
||||
import { fontIssues, type FontUsage } from './glyphs';
|
||||
import { type ContrastSample, contrastIssues } from './palette';
|
||||
import { type WeightSample, weightIssues } from './weights';
|
||||
import { pulledEntry, readPulled } from '../lib/art-pull';
|
||||
import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec';
|
||||
|
||||
|
|
@ -14,7 +16,9 @@ export type RenderReport = Record<string, { overflow: string[]; missing?: string
|
|||
/** Device frames' bounding boxes (the rotated outline, from the page's layout) in image px; bleed = cropped on purpose. */
|
||||
devices?: DeviceBox[]; bleed?: boolean;
|
||||
/** Text and logo boxes in image px (from the page's layout), for the safe-margin rule (lib.ts marginViolations). */
|
||||
boxes?: TextBox[] }>;
|
||||
boxes?: TextBox[];
|
||||
/** Title weight asked vs. served (weights.ts), and title ink contrast over the pixels behind it (palette.ts). */
|
||||
weights?: WeightSample[]; contrast?: ContrastSample[] }>;
|
||||
|
||||
/** Text or logo closer to an edge than the safe margin: an error (stores crop and round corners; it reads cramped). */
|
||||
export function marginIssues(entry: RenderReport[string] | undefined, W: number, H: number): Issue[] {
|
||||
|
|
@ -88,6 +92,7 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string
|
|||
issues.push(...fontIssues(entry?.fonts));
|
||||
issues.push(...deviceIssues(entry, size, info.w, info.h));
|
||||
issues.push(...marginIssues(entry, info.w, info.h));
|
||||
issues.push(...weightIssues(entry?.weights), ...contrastIssues(entry?.contrast));
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,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' | 'device-crop' | 'fonts' | 'margin'; msg: string }
|
||||
export interface Issue { level: IssueLevel; code: 'size' | 'alpha' | 'format' | 'bytes' | 'count' | 'overflow' | 'video' | 'font-fallback' | 'glyph-missing' | 'device-crop' | 'fonts' | 'margin' | 'font-weight' | 'contrast' | 'background'; msg: string }
|
||||
|
||||
/** Problems with one image for a size key. */
|
||||
export function checkImage(info: ImageInfo, sizeKey: string): Issue[] {
|
||||
|
|
|
|||
|
|
@ -95,12 +95,14 @@ const R = document.documentElement.style;
|
|||
// #rrggbb -> rgba with alpha, for the soft accent glow
|
||||
const rgba = (hex, a) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return hex; const n = parseInt(m[1], 16); return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`; };
|
||||
const lum = hex => { const m = /^#?([0-9a-f]{6})$/i.exec(hex); if (!m) return 1; const n = parseInt(m[1], 16); return (.299 * (n >> 16) + .587 * ((n >> 8) & 255) + .114 * (n & 255)) / 255; };
|
||||
const dark = lum(P.bg[1]) < .45;
|
||||
const tone = P.bgTone ?? (Array.isArray(P.bg) ? P.bg[1] : P.bg);
|
||||
const dark = !!P.photo && P.photo.overlay >= .35 || lum(tone) < .45;
|
||||
R.setProperty('--W', W + 'px'); R.setProperty('--H', H + 'px'); R.setProperty('--u', u + 'px');
|
||||
R.setProperty('--ink', P.ink); R.setProperty('--accent', P.glow); R.setProperty('--shadow', P.shadow);
|
||||
R.setProperty('--line2', dark ? P.glow : P.ink);
|
||||
R.setProperty('--accentSoft', rgba(P.glow, dark ? .28 : .14));
|
||||
R.setProperty('--bg', P.bg[0] === P.bg[2] && P.bg[1] === P.bg[0] ? P.bg[0] : `linear-gradient(180deg, ${P.bg[0]} 0%, ${P.bg[1]} 55%, ${P.bg[2]} 100%)`);
|
||||
// bgCss from job.json (palette.ts bgCss / photoCss): stops, one colour, a CSS gradient string, or a photo under an overlay
|
||||
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(180deg, ${P.bg.join(', ')})` : P.bg));
|
||||
const T = job.fonts.title, B = job.fonts.body, X = job.fonts.script;
|
||||
// titleStack / bodyStack (from shots fonts): script face, the configured font, then any automatic Noto fallback
|
||||
const fstack = (st, def) => (st ?? (X ? [X, def] : [def])).map(f => `'${f}'`).join(', ') + ', sans-serif';
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ const RTL = !!job.rtl;
|
|||
const R = document.documentElement.style;
|
||||
R.setProperty('--W', W + 'px'); R.setProperty('--H', H + 'px'); R.setProperty('--u', u + 'px');
|
||||
R.setProperty('--ink', P.ink); R.setProperty('--glow', P.glow); R.setProperty('--shadow', P.shadow);
|
||||
R.setProperty('--bg', `linear-gradient(180deg, ${P.bg[0]} 0%, ${P.bg[1]} 48%, ${P.bg[2]} 100%)`);
|
||||
R.setProperty('--bg', P.bgCss ?? (Array.isArray(P.bg) ? `linear-gradient(180deg, ${P.bg.join(', ')})` : P.bg)); // palette.ts: stops, colour, gradient string or photo + overlay
|
||||
// A script the title font lacks (CJK, Arabic, Cyrillic, ...): the whole line switches to the Noto subset so a word
|
||||
// never mixes two typefaces.
|
||||
const T = job.fonts.title, B = job.fonts.body, X = job.fonts.script;
|
||||
|
|
|
|||
113
cli/shots/weights.ts
Normal file
113
cli/shots/weights.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Font weights: which weights the templates ask for, how to get them from Google Fonts, and whether a rendered title
|
||||
// got the weight it asked for. The @font-face rules use font-synthesis: none, so a face that lacks a weight is drawn
|
||||
// at the nearest weight it has (a 750 headline from a 400-only face renders thin), never faked bolder.
|
||||
// shots fonts: per role (title / body) the weights of the style's templates (templateWeights); a Google family is
|
||||
// fetched as the full wght axis when it is variable, else the range the templates use, else those weights one by
|
||||
// one (static families), else the default instance (fetchFaces).
|
||||
// shots render: the title's computed weight vs. the weights the loaded faces serve (report.json `weights`);
|
||||
// shots check warns when the browser fell back to another weight (weightIssues).
|
||||
|
||||
/** Weights a page's CSS asks for, per role: rules that use var(--tf) are title text, the rest body text (the body
|
||||
* default 400 included). Literal font-weight values plus the numbers set on --tw by the page script. Pure. */
|
||||
export function templateWeights(html: string): { title: number[]; body: number[] } {
|
||||
const tw = [...html.matchAll(/setProperty\('--tw',([^;]+)\)/g)].flatMap(m => [...m[1]!.matchAll(/\b([1-9]00|[1-9][0-9]0)\b/g)].map(x => +x[1]!));
|
||||
const title = new Set<number>(), body = new Set<number>([400]);
|
||||
const style = /<style>([\s\S]*?)<\/style>/i.exec(html)?.[1] ?? '';
|
||||
for (const m of style.matchAll(/[^{}]+\{([^{}]*)\}/g)) {
|
||||
const decl = m[1]!;
|
||||
const fw = /font-weight:\s*([^;}]+)/.exec(decl)?.[1]?.trim();
|
||||
if (!fw) continue;
|
||||
const vals = fw.includes('var(--tw)') ? tw : /^\d{3}$/.test(fw) ? [+fw] : fw === 'bold' ? [700] : [];
|
||||
for (const v of vals) (decl.includes('var(--tf)') ? title : body).add(v);
|
||||
}
|
||||
const sort = (s: Set<number>): number[] => [...s].sort((a, b) => a - b);
|
||||
return { title: sort(title.size ? title : new Set([400])), body: sort(body) };
|
||||
}
|
||||
|
||||
/** Weights per role over several pages (a style's screenshot template and feature graphic). */
|
||||
export function styleWeights(pages: string[]): { title: number[]; body: number[] } {
|
||||
const t = new Set<number>(), b = new Set<number>();
|
||||
for (const p of pages) { const w = templateWeights(p); w.title.forEach(x => t.add(x)); w.body.forEach(x => b.add(x)); }
|
||||
return { title: [...t].sort((x, y) => x - y), body: [...b].sort((x, y) => x - y) };
|
||||
}
|
||||
|
||||
/** css2 axis requests to try in order: the full variable axis, the range the templates use, then the weights the
|
||||
* templates use rounded to static instances (750 -> 700 and 800). Pure. */
|
||||
export function axisCandidates(weights: number[]): string[] {
|
||||
const out = ['wght@100..900'];
|
||||
const lo = Math.min(...weights), hi = Math.max(...weights);
|
||||
if (weights.length && lo < hi) out.push(`wght@${lo}..${hi}`);
|
||||
const statics = [...new Set(weights.flatMap(w => (w % 100 ? [Math.floor(w / 100) * 100, Math.ceil(w / 100) * 100] : [w])))].sort((a, b) => a - b);
|
||||
if (statics.length) out.push(`wght@${statics.join(';')}`);
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
/** Static weights to try one by one when no combined request works. Pure. */
|
||||
export function staticWeights(weights: number[]): number[] {
|
||||
return [...new Set(weights.flatMap(w => (w % 100 ? [Math.floor(w / 100) * 100, Math.ceil(w / 100) * 100] : [w])))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** @font-face blocks of a css2 response: weight descriptor ("400", "200 800") and the font URL. Pure. */
|
||||
export function parseFaces(css: string): { weight: string; url: string }[] {
|
||||
return [...css.matchAll(/@font-face\s*\{([^}]*)\}/g)].flatMap(m => {
|
||||
const url = /url\((https:[^)]+)\)/.exec(m[1]!)?.[1];
|
||||
const weight = /font-weight:\s*([\d ]+)/.exec(m[1]!)?.[1]?.trim() ?? '400';
|
||||
return url ? [{ weight, url }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether a weight descriptor ("700", "200 800") serves a weight. */
|
||||
export function serves(desc: string, w: number): boolean {
|
||||
const [a, b] = desc.trim().split(/\s+/).map(Number) as [number, number | undefined];
|
||||
return b === undefined ? a === w : w >= a && w <= b;
|
||||
}
|
||||
|
||||
export interface FetchedFace { weight: string; bin: ArrayBuffer }
|
||||
type Fetch = (url: string, init?: { headers?: Record<string, string> }) => Promise<{ ok: boolean; status: number; text(): Promise<string>; arrayBuffer(): Promise<ArrayBuffer> }>;
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||
|
||||
/** Downloads a Google family subset to `chars` with the weights the templates use (see axisCandidates). `axis`
|
||||
* given: that one request (fallback faces), else the weight strategy. fetchFn is injectable (tests run offline). */
|
||||
export async function fetchFaces(family: string, chars: string, o: { weights?: number[]; axis?: string | null }, fetchFn: Fetch = fetch as unknown as Fetch): Promise<FetchedFace[]> {
|
||||
const css2 = async (axis: string | null): Promise<{ weight: string; url: string }[] | null> => {
|
||||
const fam = encodeURIComponent(family) + (axis ? ':' + axis : '');
|
||||
const res = await fetchFn(`https://fonts.googleapis.com/css2?family=${fam}&text=${encodeURIComponent(chars)}&display=block`, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok) { if (axis) return null; throw new Error(`${family}: ${res.status} ${(await res.text()).slice(0, 200)}`); }
|
||||
const faces = parseFaces(await res.text());
|
||||
if (!faces.length) throw new Error(`${family}: no font url`);
|
||||
return faces;
|
||||
};
|
||||
let faces: { weight: string; url: string }[] | null = null;
|
||||
if (o.weights?.length && o.axis === undefined) {
|
||||
for (const axis of axisCandidates(o.weights)) if ((faces = await css2(axis))) break;
|
||||
if (!faces) { // a static family: each weight it has, one by one
|
||||
const got: { weight: string; url: string }[] = [];
|
||||
for (const w of staticWeights(o.weights)) got.push(...((await css2(`wght@${w}`)) ?? []));
|
||||
faces = got.length ? got : null;
|
||||
}
|
||||
} else if (o.axis) faces = await css2(o.axis);
|
||||
faces ??= await css2(null);
|
||||
const out: FetchedFace[] = [];
|
||||
for (const f of faces!) {
|
||||
const res = await fetchFn(f.url, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok) throw new Error(`${family}: ${res.status} for ${f.url}`);
|
||||
out.push({ weight: f.weight, bin: await res.arrayBuffer() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Weights a role asks for that a local font file's declared weight does not serve (no declared weight: the file is
|
||||
* declared for 100-900, so a static file draws its own weight for all of them). */
|
||||
export function localWeightGap(declared: string | undefined, want: number[]): number[] {
|
||||
return declared === undefined ? [] : want.filter(w => !serves(declared, w));
|
||||
}
|
||||
|
||||
/** One title element as `shots render` records it: the weight asked for, the family, and the weight descriptors of
|
||||
* that family's loaded faces. */
|
||||
export interface WeightSample { el: string; family: string; want: number; served: string[] }
|
||||
|
||||
/** The browser drew a title at another weight than asked (no loaded face serves it): a warning. */
|
||||
export function weightIssues(samples: WeightSample[] | undefined): { level: 'warn'; code: 'font-weight'; msg: string }[] {
|
||||
return (samples ?? []).filter(s => s.served.length && !s.served.some(d => serves(d, s.want))).map(s => ({ level: 'warn' as const, code: 'font-weight' as const,
|
||||
msg: `${s.el} asks for weight ${s.want} but ${s.family} is loaded only as ${s.served.join(', ')}, so the browser drew another weight (thin headline): run \`storebound shots fonts\` again (it now fetches the weights the templates use), pick a family with that weight, or give a local font file's real weight` }));
|
||||
}
|
||||
|
|
@ -102,6 +102,19 @@ skills.
|
|||
- **Safe margins:** every template keeps text and logo 7 % of the width from the sides and 5 % of the height from the
|
||||
top; `shots check` measures the layout boxes recorded at render time and fails anything under 6 % / 4 %.
|
||||
|
||||
## Palettes, photo backgrounds and title weight
|
||||
- **palette.bg** takes gradient stops (2-3 colours), one colour (`'#15171C'`, flat) or a CSS gradient string (passed
|
||||
through). Anything else fails validation: "palette.bg must be a colour, a gradient or an array of 2-3 colours".
|
||||
- **Photo backgrounds:** `palette.image` (+ `overlay`, default 0.45) for scenes, `shots.feature.background` (a path or
|
||||
`{ image, overlay }`) for the Play feature graphic. Cropped to cover under a flat dark overlay. Play's feature
|
||||
graphic can be a branded photo; the App Store has no feature graphic, but the same art fits the Header and Search
|
||||
Results slots below. `shots check`: the file exists and is at least the output size.
|
||||
- **Contrast guard:** `shots render` hides the title, samples the pixels behind it and records the worst ink contrast
|
||||
in report.json; `shots check` warns under 3:1.
|
||||
- **Title weight:** `shots fonts` fetches the weights the templates use (full `wght` axis, else the used range, else
|
||||
static weights); `shots check` warns when a title was drawn at a weight no loaded face serves (font-synthesis is
|
||||
off, so a 400-only face draws a 750 headline thin).
|
||||
|
||||
## Header and Search Results (App Store creative assets, iOS / iPadOS 27+)
|
||||
Since 5 Oct 2026 the App Store version page has a "Header and Search Results" tab next to "App Previews and
|
||||
Screenshots". Optional: without these assets the App Store uses in-app events, app previews and screenshots.
|
||||
|
|
|
|||
|
|
@ -1026,3 +1026,15 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
`play upload` / `play release` / `apple upload` block before any network call, naming the requirement, date and fix.
|
||||
- **Guard:** the dated tables `PLAY_TARGET_SDK_TABLE` and `APPLE_MIN_SDK_TABLE` in `cli/lib/store-minimums.ts` (a new
|
||||
year is one row); `tests/store-minimums.test.ts`.
|
||||
|
||||
## 84. Thin app-style headlines, white backgrounds from a string palette.bg
|
||||
- **Symptom:** a variable Google title family (a 200..800 axis) was downloaded as its default 400 instance, so the
|
||||
750 headlines rendered thin (font-synthesis is off); a custom palette with `bg: '#15171C'` or a gradient string
|
||||
rendered a white background, and white `ink` headlines vanished.
|
||||
- **Do:** run `storebound shots fonts` again (it fetches the weights the templates use: full axis, used range, or
|
||||
static weights) and `shots render`. Use `bg` as stops, one colour or a CSS gradient string; for a photo use
|
||||
`palette.image` / `shots.feature.background` with an overlay. Read the `font-weight` and `contrast` warnings of
|
||||
`shots check` before uploading.
|
||||
- **Guard:** `cli/shots/weights.ts` (templateWeights, fetchFaces, weightIssues), `cli/shots/palette.ts` (bgIssue in
|
||||
config validation, bgCss, contrastIssues, coverCrop); report.json `weights` / `contrast`; `tests/shots-look.test.ts`,
|
||||
`tests/browser/palette-bg.browser.ts`.
|
||||
|
|
|
|||
|
|
@ -170,6 +170,20 @@ the icon, feature graphic and phone screenshots and prints `tablet screenshots:
|
|||
`{ bg, glow, ink, shadow }`), frames (one front phone + optional back phones with x / dy / scale / rotate), badge
|
||||
slots (y, side, icon, hot, stars). Fonts default to Paytone One + Montserrat (game) or Inter (app): all cover
|
||||
Latin Extended and Vietnamese.
|
||||
**Custom palette bg:** `['#a', '#b', '#c']` (or two stops), one colour `'#15171C'` (flat) or a CSS gradient string
|
||||
`'linear-gradient(160deg, #0b1736, #1d4ed8)'` (passed through). Anything else fails config validation and render
|
||||
("palette.bg must be a colour, a gradient or an array of 2-3 colours"); a dark bg needs a light `ink`.
|
||||
**Photo background (app style):** `palette.image: 'store/art/hero.png'` (+ `overlay`, 0-0.9, default 0.45: a flat
|
||||
dark layer that keeps white ink legible) on a scene palette, or `shots.feature.background: 'store/art/hero.png' |
|
||||
{ image, overlay }` for the Play feature graphic only. The image is cropped to cover; `shots check` errors on a
|
||||
missing file and warns when it is smaller than the output (1024 x 500 for the feature graphic, the biggest
|
||||
screenshot size for scenes). Play's feature graphic may be a branded photo (keep text out of it apart from the
|
||||
name); the App Store has no feature graphic, but the same art can be the Header and Search Results asset below.
|
||||
**Title weight:** `shots fonts` downloads the weights the templates use (app: 700 / 750 titles, 400-700 body): the
|
||||
full `wght` axis of a variable Google family, else the range the templates use, else the static weights one by one.
|
||||
A family that lacks a weight is drawn at the nearest one (never faked bolder), so `shots fonts` warns, and
|
||||
`shots check` warns per image when the title's weight was not served (`font-weight`). A local font file declares
|
||||
its weight with `{ family, file, weight }`; a static file needs it.
|
||||
6. **Render.** (`--concurrency N`, default min(4, cores / 2) pages) `storebound shots render --lang en` first, review `store/renders/en/sheet.png` (contact sheet, sizes
|
||||
packed into a grid), then
|
||||
all languages in the background: `storebound shots render > .storebound/render.log 2>&1 &`.
|
||||
|
|
@ -177,7 +191,9 @@ 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), a device
|
||||
automatic font fallback or a system font (errors; `shots render` measures every character in the page), a title
|
||||
drawn at another weight than asked (`font-weight`, warning), title ink with low contrast against the pixels behind
|
||||
it (`contrast`, under 3:1, warning: catches white on white), photo backgrounds missing or too small, 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
|
||||
|
|
|
|||
65
tests/browser/palette-bg.browser.ts
Normal file
65
tests/browser/palette-bg.browser.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Regression: an app-style palette with a string bg ('#15171C' or 'linear-gradient(...)') rendered a WHITE background
|
||||
// (the template indexed bg[0..2] of a string), so white ink headlines vanished. Now the bg goes through palette.ts
|
||||
// bgCss, and `shots render` samples the pixels behind the title (titleContrast) for `shots check`.
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DEFAULT_PHONE, DEFAULT_TABLET } from '../../cli/shots/lib';
|
||||
import { bgCss, contrastIssues, photoCss } from '../../cli/shots/palette';
|
||||
import { titleContrast } from '../../cli/shots/render';
|
||||
import { AFTER_ALL_MS, launchTestBrowser, scaled } from '../helpers/browser';
|
||||
|
||||
const shared = await launchTestBrowser('playwright-core');
|
||||
const { browser } = shared;
|
||||
if (!browser) console.warn(`palette bg render test skipped: no Chromium installed (${shared.skipWhy}).`);
|
||||
afterAll(() => shared.close(), AFTER_ALL_MS);
|
||||
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
|
||||
const html = readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'template-app.html'), 'utf8');
|
||||
|
||||
async function render(bg: unknown, ink: string, photo = false): Promise<{ contrast: Awaited<ReturnType<typeof titleContrast>>; bgImage: string; bgColor: string }> {
|
||||
const page = await browser.newPage({ viewport: { width: 540, height: 960 } });
|
||||
try {
|
||||
const pal = { bg, glow: '#93c5fd', ink, shadow: 'rgba(0,0,0,.4)' };
|
||||
const css = photo ? photoCss('/bgimg/0', 0.6) + ', ' + bgCss(bg as never) : bgCss(bg as never);
|
||||
const job = { style: 'app', phone: DEFAULT_PHONE, tablet: DEFAULT_TABLET, landscape: { w: 1440, h: 900, bar: 0 }, side: 'left', logo: false, appName: 'Demo',
|
||||
fonts: { title: 'X', body: 'Y' }, number: 1, scene: { id: 'a', frames: [{ src: 'a.png' }] },
|
||||
palette: { ...pal, bgCss: css, bgTone: photo ? '#000000' : undefined, ...(photo ? { photo: { overlay: 0.6 } } : {}) },
|
||||
copy: { eyebrow: 'Notes', title: ['Calm notes', 'for busy days'], sub: 'Synced everywhere.' } };
|
||||
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.endsWith('.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/') || u.pathname.startsWith('/bgimg/')) return r.fulfill({ contentType: 'image/png', body: PNG });
|
||||
return r.fulfill({ contentType: 'text/css', body: '' });
|
||||
});
|
||||
await page.goto('http://t.test/template-app.html?s=a&w=540&h=960&lang=en');
|
||||
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 });
|
||||
const st = JSON.parse(await page.evaluate("JSON.stringify((s => ({ i: s.backgroundImage, c: s.backgroundColor }))(getComputedStyle(document.getElementById('c'))))")) as { i: string; c: string };
|
||||
return { contrast: await titleContrast(page, 540, 960), bgImage: st.i, bgColor: st.c };
|
||||
} finally { await page.close(); }
|
||||
}
|
||||
|
||||
describe.skipIf(!browser)('app template: palette bg forms and the contrast guard', () => {
|
||||
test('a flat colour string renders that colour, white ink passes', async () => {
|
||||
const r = await render('#15171C', '#ffffff');
|
||||
expect(r.bgColor).toBe('rgb(21, 23, 28)');
|
||||
expect(r.contrast.length).toBeGreaterThan(0);
|
||||
expect(contrastIssues(r.contrast)).toEqual([]);
|
||||
}, scaled(30_000));
|
||||
test('a gradient string passes through', async () => {
|
||||
const r = await render('linear-gradient(160deg, #0b1736, #1d4ed8)', '#ffffff');
|
||||
expect(r.bgImage).toContain('linear-gradient');
|
||||
expect(contrastIssues(r.contrast)).toEqual([]);
|
||||
}, scaled(30_000));
|
||||
test('white ink on a white bg is flagged', async () => {
|
||||
const r = await render(['#ffffff', '#fafafa', '#ffffff'], '#ffffff');
|
||||
expect(contrastIssues(r.contrast).map(i => i.code)).toContain('contrast');
|
||||
}, scaled(30_000));
|
||||
test('a photo under the overlay keeps white ink legible', async () => {
|
||||
const r = await render('#ffffff', '#ffffff', true);
|
||||
expect(r.bgImage).toContain('/bgimg/0');
|
||||
expect(contrastIssues(r.contrast)).toEqual([]);
|
||||
}, scaled(30_000));
|
||||
});
|
||||
|
||||
141
tests/shots-look.test.ts
Normal file
141
tests/shots-look.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Real use (app style): a variable Google title family was downloaded as its 400 instance (thin headlines under
|
||||
// font-synthesis: none), a string palette.bg rendered white (white ink vanished), and photo backgrounds were missing.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { validateConfig } from '../cli/lib/config';
|
||||
import { palette } from '../cli/shots/lib';
|
||||
import { BG_ERROR, bgCss, bgIssue, contrastIssues, contrastRatio, coverCrop, featureBackgroundIssues, paletteIssues, parseColour, photoBackground, photoCss, photoIssues, worstContrast } from '../cli/shots/palette';
|
||||
import { axisCandidates, fetchFaces, localWeightGap, parseFaces, serves, templateWeights, weightIssues } from '../cli/shots/weights';
|
||||
import { weightsFor } from '../cli/shots/fonts';
|
||||
import { fontsCss, planFonts } from '../cli/shots/glyphs';
|
||||
|
||||
/** Google css2 mock: `ranges` = the wght range a variable family has, `statics` = the weights a static family has. */
|
||||
function google(o: { ranges?: [number, number]; statics?: number[] }) {
|
||||
const asked: string[] = [];
|
||||
const fn = async (url: string) => {
|
||||
asked.push(url);
|
||||
if (url.startsWith('https://fonts.gstatic.com/')) return { ok: true, status: 200, text: async () => '', arrayBuffer: async () => new TextEncoder().encode(url).buffer as ArrayBuffer };
|
||||
const axis = /family=[^:&]+(?::wght@([^&]+))?/.exec(decodeURIComponent(url))![1];
|
||||
const fail = { ok: false, status: 400, text: async () => 'bad', arrayBuffer: async () => new ArrayBuffer(0) };
|
||||
const face = (w: string) => `@font-face { font-family: 'X'; font-weight: ${w}; src: url(https://fonts.gstatic.com/x/${w.replace(' ', '-')}.woff2) format('woff2'); }\n`;
|
||||
const ok = (css: string) => ({ ok: true, status: 200, text: async () => css, arrayBuffer: async () => new ArrayBuffer(0) });
|
||||
if (!axis) return ok(face(String(o.statics?.[0] ?? 400)));
|
||||
const r = /^(\d+)\.\.(\d+)$/.exec(axis);
|
||||
if (r) return o.ranges && +r[1]! >= o.ranges[0] && +r[2]! <= o.ranges[1] ? ok(face(`${r[1]} ${r[2]}`)) : fail;
|
||||
const ws = axis.split(';').map(Number);
|
||||
const has = (w: number) => (o.ranges ? w >= o.ranges[0] && w <= o.ranges[1] : !!o.statics?.includes(w));
|
||||
return ws.every(has) ? ok(ws.map(w => face(String(w))).join('')) : fail;
|
||||
};
|
||||
return { fn, asked };
|
||||
}
|
||||
|
||||
describe('font weights', () => {
|
||||
test('the app templates ask the title for 700 / 750 and the body for 400-700', () => {
|
||||
const w = weightsFor('app');
|
||||
expect(w.title).toEqual([700, 750]);
|
||||
expect(w.body).toEqual(expect.arrayContaining([400, 500, 600, 700]));
|
||||
expect(templateWeights('<style>h1 { font-family: var(--tf); font-weight: var(--tw); }</style><script>R.setProperty(\'--tw\', 800);</script>').title).toEqual([800]);
|
||||
});
|
||||
test('candidates: full axis, the used range, then rounded statics', () => {
|
||||
expect(axisCandidates([700, 750])).toEqual(['wght@100..900', 'wght@700..750', 'wght@700;800']);
|
||||
expect(axisCandidates([400])).toEqual(['wght@100..900', 'wght@400']);
|
||||
});
|
||||
test('variable family with a 200..800 axis (Plus Jakarta Sans): gets the 700..750 range, not the 400 default', async () => {
|
||||
const g = google({ ranges: [200, 800] });
|
||||
const faces = await fetchFaces('Plus Jakarta Sans', 'Ab', { weights: [700, 750] }, g.fn);
|
||||
expect(faces.map(f => f.weight)).toEqual(['700 750']);
|
||||
expect(g.asked.filter(u => u.includes('googleapis')).map(u => decodeURIComponent(u).match(/:wght@([^&]+)/)?.[1])).toEqual(['100..900', '700..750']);
|
||||
expect(serves('700 750', 750)).toBe(true);
|
||||
});
|
||||
test('variable family with the full axis: one request', async () => {
|
||||
const g = google({ ranges: [100, 900] });
|
||||
expect((await fetchFaces('Inter', 'Ab', { weights: [700, 750] }, g.fn)).map(f => f.weight)).toEqual(['100 900']);
|
||||
});
|
||||
test('static family: the weights it has, one by one', async () => {
|
||||
const g = google({ statics: [400, 700] });
|
||||
const faces = await fetchFaces('Static Sans', 'Ab', { weights: [700, 750] }, g.fn);
|
||||
expect(faces.map(f => f.weight)).toEqual(['700']);
|
||||
const one = await fetchFaces('Paytone One', 'Ab', { weights: [900] }, google({ statics: [400] }).fn);
|
||||
expect(one.map(f => f.weight)).toEqual(['400']); // only the default instance exists
|
||||
});
|
||||
test('parseFaces and fontsCss declare each file with its own weight', async () => {
|
||||
expect(parseFaces("@font-face { font-weight: 200 800; src: url(https://a/b.woff2) }")).toEqual([{ weight: '200 800', url: 'https://a/b.woff2' }]);
|
||||
const m = await planFonts({ langs: { en: { title: 'Ab', body: 'Ab' } }, title: 'T', body: 'B', titleAxis: null, bodyAxis: null, titleWeights: [700, 750], bodyWeights: [400], fallbackTitleAxis: null },
|
||||
async job => job.weights && job.kind === 'title' ? { cps: new Set([65, 98]), files: [{ file: 'title-700.woff2', weight: '700' }, { file: 'title-800.woff2', weight: '800' }] } : new Set([65, 98]));
|
||||
const css = fontsCss(m);
|
||||
expect(css).toContain("font-family: 'T'; font-weight: 700; font-synthesis: none; src: url(title-700.woff2)");
|
||||
expect(css).toContain("font-family: 'T'; font-weight: 800;");
|
||||
expect(css).toContain("font-family: 'B'; font-weight: 100 900;");
|
||||
});
|
||||
test('local font: a declared weight that cannot serve the asked weight', () => {
|
||||
expect(localWeightGap('400', [700, 750])).toEqual([700, 750]);
|
||||
expect(localWeightGap('100 900', [700, 750])).toEqual([]);
|
||||
expect(localWeightGap(undefined, [700])).toEqual([]);
|
||||
});
|
||||
test('shots check warns when the title was drawn at another weight', () => {
|
||||
const w = weightIssues([{ el: 'title', family: 'Plus Jakarta Sans', want: 750, served: ['400'] }]);
|
||||
expect(w.map(i => [i.level, i.code])).toEqual([['warn', 'font-weight']]);
|
||||
expect(w[0]!.msg).toContain('weight 750');
|
||||
expect(weightIssues([{ el: 'title', family: 'Inter', want: 750, served: ['100 900'] }])).toEqual([]);
|
||||
expect(weightIssues([{ el: 'title', family: 'X', want: 750, served: [] }])).toEqual([]); // not our face: no claim
|
||||
});
|
||||
});
|
||||
|
||||
describe('palette bg', () => {
|
||||
test('stops, one colour and a gradient string', () => {
|
||||
expect(bgCss(['#111111', '#222222', '#333333'], 180, 55)).toBe('linear-gradient(180deg, #111111 0%, #222222 55%, #333333 100%)');
|
||||
expect(bgCss(['#111111', '#222222'], 135)).toBe('linear-gradient(135deg, #111111 0%, #222222 100%)');
|
||||
expect(bgCss('#15171C')).toBe('#15171C');
|
||||
expect(bgCss('linear-gradient(160deg, #0b1736, #1d4ed8)')).toBe('linear-gradient(160deg, #0b1736, #1d4ed8)');
|
||||
for (const ok of ['#15171C', 'rgb(10, 20, 30)', 'navy', 'radial-gradient(circle, #000, #333)', ['#000', '#111'], ['#000', '#111', '#222']]) expect(bgIssue(ok)).toBeNull();
|
||||
});
|
||||
test('anything else is the validation error, in config validation and when resolved', () => {
|
||||
for (const bad of ['', 'url(x.png)', ['#000'], ['#000', '#111', '#222', '#333'], 42, ['#000', 'not a colour!']]) expect(bgIssue(bad)).toBe(BG_ERROR);
|
||||
const pal = { bg: 'url(x.png)', glow: '#fff', ink: '#fff', shadow: 'rgba(0,0,0,.4)' };
|
||||
expect(() => palette(pal as never, 'app')).toThrow(BG_ERROR);
|
||||
const errs = validateConfig({ app: { name: 'X', profile: 'app' }, languages: [{ id: 'en' }],
|
||||
shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', palette: pal, frames: [{ src: 'a.png' }] }] } } as never).filter(e => e.startsWith('shots.'));
|
||||
expect(errs).toEqual([`shots.scenes[a].palette.bg: ${BG_ERROR} (got "url(x.png)")`]);
|
||||
expect(paletteIssues({ bg: '#15171C', glow: '#fff', ink: '#fff', shadow: '#000' }, 'p')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contrast guard', () => {
|
||||
test('white ink on white is caught, white on dark passes', () => {
|
||||
const white = parseColour('rgb(255, 255, 255)')!;
|
||||
expect(contrastRatio(white, [0, 0, 0])).toBeCloseTo(21, 0);
|
||||
const bad = worstContrast(white, [[21, 23, 28], [250, 250, 250]]);
|
||||
expect(bad.worst).toEqual([250, 250, 250]);
|
||||
const w = contrastIssues([{ el: 'title', ink: 'rgb(255, 255, 255)', worst: bad.worst, ratio: bad.ratio }]);
|
||||
expect(w.map(i => [i.level, i.code])).toEqual([['warn', 'contrast']]);
|
||||
expect(w[0]!.msg).toContain('close to invisible');
|
||||
const good = worstContrast(white, [[21, 23, 28], [30, 30, 40]]);
|
||||
expect(contrastIssues([{ el: 'title', ink: '#fff', worst: good.worst, ratio: good.ratio }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo backgrounds', () => {
|
||||
test('config: palette.image / overlay and shots.feature.background', () => {
|
||||
const p = { bg: '#000', glow: '#fff', ink: '#fff', shadow: '#000' };
|
||||
expect(paletteIssues({ ...p, image: 'store/art/x.png', overlay: 0.5 }, 'p')).toEqual([]);
|
||||
expect(paletteIssues({ ...p, image: 'x.gif', overlay: 2 }, 'p')).toEqual(['p.image: a PNG, JPEG or WebP path (relative to the config)', 'p.overlay: a number from 0 to 0.9 (dark overlay over the image)']);
|
||||
expect(featureBackgroundIssues('store/art/hero.jpg')).toEqual([]);
|
||||
expect(featureBackgroundIssues({ image: 'a.png', overlay: 0.3 })).toEqual([]);
|
||||
expect(featureBackgroundIssues({ overlay: 0.3 })).toHaveLength(1);
|
||||
expect(featureBackgroundIssues({ image: 'a.png', overlay: 1 })).toEqual(['shots.feature.background.overlay: a number from 0 to 0.9']);
|
||||
expect(photoBackground(p as never, 'hero.png')).toEqual({ image: 'hero.png', overlay: 0.45 });
|
||||
expect(photoBackground({ ...p, image: 'a.png', overlay: 0.2 } as never)).toEqual({ image: 'a.png', overlay: 0.2 });
|
||||
expect(photoBackground(p as never)).toBeNull();
|
||||
expect(photoCss('/bgimg/0', 0.45)).toBe("linear-gradient(rgba(0,0,0,0.45), rgba(0,0,0,0.45)), url('/bgimg/0') center / cover no-repeat");
|
||||
});
|
||||
test('cover crop: centred, the box aspect, scale > 1 = too small', () => {
|
||||
const c = coverCrop(2048, 2048, 1024, 500);
|
||||
expect(c.scale).toBe(0.5);
|
||||
expect([c.x, c.y, c.w, c.h]).toEqual([0, 524, 2048, 1000]);
|
||||
const t = coverCrop(1000, 2000, 1080, 1920);
|
||||
expect(t.w / t.h).toBeCloseTo(1080 / 1920, 6);
|
||||
expect(t.x).toBeCloseTo(0, 6);
|
||||
expect(photoIssues({ w: 2048, h: 1000 }, 'a.png', 1024, 500)).toEqual([]);
|
||||
expect(photoIssues({ w: 800, h: 400 }, 'a.png', 1024, 500)[0]!.msg).toContain('smaller than the 1024x500 output');
|
||||
expect(photoIssues(null, 'a.png', 1024, 500)[0]!.level).toBe('error');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue