diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c66770..951d487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 445e508..147ca9d 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -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) { diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index 1a18a42..f5917f9 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -38,10 +38,10 @@ export const TEMPLATE_TEXT: Record = { /** The pages shots render / video load (cli/shots/*.html), by file name. */ export const SHOTS_PAGES: Record = { "caption.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", - "feature-app.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", - "feature.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", - "template-app.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", - "template.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", + "feature-app.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", + "feature.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", + "template-app.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", + "template.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", }; /** Play Console's Data safety form export (cli/data/play-data-safety-form.csv). */ diff --git a/cli/shots/check.ts b/cli/shots/check.ts index cc45379..d7f8f85 100644 --- a/cli/shots/check.ts +++ b/cli/shots/check.ts @@ -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 { 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 }); diff --git a/cli/shots/feature-app.html b/cli/shots/feature-app.html index 8a065f5..31ba34d 100644 --- a/cli/shots/feature-app.html +++ b/cli/shots/feature-app.html @@ -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}`); diff --git a/cli/shots/feature.html b/cli/shots/feature.html index 792cd3b..b7147f6 100644 --- a/cli/shots/feature.html +++ b/cli/shots/feature.html @@ -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}`); diff --git a/cli/shots/fonts.ts b/cli/shots/fonts.ts index 5c12828..2f24be8 100644 --- a/cli/shots/fonts.ts +++ b/cli/shots/fonts.ts @@ -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 { - 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 { 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 => { 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 { 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(); } - 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) { diff --git a/cli/shots/glyphs.ts b/cli/shots/glyphs.ts index 34168d1..bc2fdde 100644 --- a/cli/shots/glyphs.ts +++ b/cli/shots/glyphs.ts @@ -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 | { cps: Set; 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; + faces: Record; langs: Record; } @@ -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>): Promise { +export async function planFonts(input: PlanInput, get0: (job: FaceJob) => Promise): Promise { + const files = new Map(); + const get = async (job: FaceJob): Promise> => { + 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 => { 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; diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index 0e4abfd..b440186 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -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 = { 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; diff --git a/cli/shots/palette.ts b/cli/shots/palette.ts new file mode 100644 index 0000000..2daeca1 --- /dev/null +++ b/cli/shots/palette.ts @@ -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, 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` })); +} diff --git a/cli/shots/render.ts b/cli/shots/render.ts index 3ab7876..4aa751d 100644 --- a/cli/shots/render.ts +++ b/cli/shots/render.ts @@ -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 { 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/ + const photos: string[] = []; + const look = (pl: Palette, angle: number, mid: number, featureBg?: NonNullable['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 { 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 { 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 { 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 { + 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 { + 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; +} diff --git a/cli/shots/report.ts b/cli/shots/report.ts index 4cc9fbf..f197ada 100644 --- a/cli/shots/report.ts +++ b/cli/shots/report.ts @@ -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; + 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 }); } diff --git a/cli/shots/spec.ts b/cli/shots/spec.ts index bbb21be..10d9bd1 100644 --- a/cli/shots/spec.ts +++ b/cli/shots/spec.ts @@ -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[] { diff --git a/cli/shots/template-app.html b/cli/shots/template-app.html index b72dac3..2758074 100644 --- a/cli/shots/template-app.html +++ b/cli/shots/template-app.html @@ -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'; diff --git a/cli/shots/template.html b/cli/shots/template.html index 7cdcf70..524a08e 100644 --- a/cli/shots/template.html +++ b/cli/shots/template.html @@ -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; diff --git a/cli/shots/weights.ts b/cli/shots/weights.ts new file mode 100644 index 0000000..6bedac8 --- /dev/null +++ b/cli/shots/weights.ts @@ -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(), body = new Set([400]); + const style = /').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'); + }); +});