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:
CRTkafa 2026-10-20 13:37:37 +07:00
parent 2ba8278ab7
commit 3a11682a37
21 changed files with 713 additions and 44 deletions

View 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
View 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');
});
});